From ce07537eef287aea5d4e8fd495fe03a6468b9c95 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 19 Jun 2026 14:31:33 +0900 Subject: [PATCH] ... --- src/main/kotlin/analyzer/TechnicalAnalyzer.kt | 392 +++++++++++++++++- src/main/kotlin/model/TradeModels.kt | 12 +- src/main/kotlin/network/KisTradeService.kt | 6 +- src/main/kotlin/network/RagService.kt | 2 + src/main/kotlin/service/AutoTradingManager.kt | 70 +++- 5 files changed, 463 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt index 84ebff0..e0a9064 100644 --- a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt +++ b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt @@ -36,6 +36,83 @@ class TechnicalAnalyzer { fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() } + + /** + * [신규] 기간별(1M, 6M, 1Y) 최고가 저항선 근접 여부를 판단하여 감점 산출 + */ + fun calculateHighPricePenalty(): Double { + if (daily.isEmpty()) return 0.0 + + val currentPrice = daily.last().stck_prpr.toDouble() + var penalty = 0.0 + + // 1. 최근 1달 (일봉 20개) 최고가 대비 감점 + if (daily.size >= 20) { + val max1M = daily.takeLast(20).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0 + // 현재가가 1달 최고가를 뚫었거나 최고가의 98% 이상 바짝 붙었을 때 단기 매물대 저항 감점 + if (max1M > 0 && currentPrice >= max1M * 0.98) { + penalty -= 3.0 + } + } + + // 2. 최근 6개월 (주봉 26개) 최고가 대비 감점 + if (weekly.size >= 26) { + val max6M = weekly.takeLast(26).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0 + if (max6M > 0 && currentPrice >= max6M * 0.97) { + penalty -= 4.0 + } + } + + // 3. 최근 1년 (주봉 52개 또는 월봉 12개) 최고가 대비 감점 + if (weekly.size >= 52) { + val max1Y = weekly.takeLast(52).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0 + if (max1Y > 0 && currentPrice >= max1Y * 0.95) { + penalty -= 5.0 + } + } else if (monthly.size >= 12) { // 주봉이 부족할 경우 월봉으로 대체 대안 + val max1Y = monthly.takeLast(12).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0 + if (max1Y > 0 && currentPrice >= max1Y * 0.95) { + penalty -= 5.0 + } + } + + return penalty + } + + /** + * [신규] 단기 낙폭 과대 후 바닥을 다지고 돌아서는 '반등 추세(Turnaround)' 확인 시 가점 산출 + */ + fun calculateReboundBonus(): Double { + if (daily.size < 10) return 0.0 + + // 최근 10일의 데이터를 쪼개어 흐름 분석 (과거 7일 vs 최근 3일) + val past7Days = daily.takeLast(10).take(7) + val recent3Days = daily.takeLast(3) + + val pastChange = calculateChange(past7Days) // 이전 7일간의 등락률 + val recentChange = calculateChange(recent3Days) // 최근 3일간의 등락률 + + // 조건: 앞선 7일 동안은 $-3.0\%$ 이하로 밀리며 역배열 혹은 투매가 나왔으나, + // 최근 3일간 $+2.5\%$ 이상 강하게 단기 정배열 전환 혹은 양봉 밀집 반등이 일어날 때 + if (pastChange <= -3.0 && recentChange >= 2.5) { + return 8.0 // 반등 성공 가점 + } + + // 대안 조건: 5일 이동평균선(MA5)의 하락 추세 멈춤 및 상향 턴어라운드(V자 반등) 지점 포착 + if (daily.size >= 7) { + val ma5Today = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average() + val ma5Yesterday = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average() + val ma5TwoDaysAgo = daily.dropLast(2).takeLast(5).map { it.stck_prpr.toDouble() }.average() + + // 2일 전까지는 이평선이 내려앉다가 오늘 고개를 드는 변곡점 형태 + if (ma5Today > ma5Yesterday && ma5Yesterday < ma5TwoDaysAgo) { + return 5.0 + } + } + + return 0.0 + } + /** * 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성 */ @@ -49,8 +126,18 @@ class TechnicalAnalyzer { // 2. 점수 정교화 (가점/감점 요인) // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 - if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) { - refinedScore += 10.0 + val trendConditions = listOf( + calculateChange(monthly) > 0, // 장기 추세 + calculateChange(weekly) > 0, // 중기 추세 + calculateChange(daily.takeLast(5)) > 0 // 단기 추세 + ) + + val passedCount = trendConditions.count { it == true } + + if (passedCount >= 2) { + refinedScore += 10.0 // 2개 이상이 상승 추세면 가점 부여 + } else if (passedCount == 3) { + refinedScore += 15.0 // 3개 모두 일치하면 '초강력 추세'로 보너스 추가 가점 (선택 사항) } // [보완] 자금 유입 강도(MFI) 반영 @@ -67,6 +154,14 @@ class TechnicalAnalyzer { val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble()) if (bodyRange > atr * 1.2) refinedScore += 7.0 + // 🌟 [추가 보완 1] 기간별 최고가 저항선 감점 적용 + val highPricePenalty = calculateHighPricePenalty() + refinedScore += highPricePenalty // 음수 값이 반환되므로 가산 + + // 🌟 [추가 보완 2] 낙폭 과대 후 단기 반등 추세 가점 적용 + val reboundBonus = calculateReboundBonus() + refinedScore += reboundBonus + // 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석 if (min30.size >= 15) { val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들) @@ -99,6 +194,65 @@ class TechnicalAnalyzer { ) } + /** + * [신규] 종목의 평균 반등 텀(캔들 수)을 계산합니다. + * * @param candles 분석할 캔들 리스트 (daily, weekly 등) + * @param dropThreshold 고점 대비 이 비율(%)만큼 떨어지면 하락으로 간주 (기본 5.0%) + * @param reboundThreshold 바닥 대비 이 비율(%)만큼 오르면 반등으로 간주 (기본 3.0%) + * @return 평균 반등에 소요된 캔들 수 (사이클이 없으면 0.0 반환) + */ + fun calculateAverageReboundTerm( + candles: List, + dropThreshold: Double = 5.0, + reboundThreshold: Double = 3.0 + ): Double { + if (candles.size < 10) return 0.0 + + var peakPrice = candles.first().stck_hgpr.toDouble() + var bottomPrice = peakPrice + var bottomIndex = 0 + + var isDropping = false + val reboundTerms = mutableListOf() + + for (i in candles.indices) { + val currentHigh = candles[i].stck_hgpr.toDouble() + val currentLow = candles[i].stck_lwpr.toDouble() + val currentClose = candles[i].stck_prpr.toDouble() + + if (!isDropping) { + // 1. 상승/횡보 구간: 고점 갱신 확인 + if (currentHigh > peakPrice) { + peakPrice = currentHigh + } + // 고점 대비 특정 비율(dropThreshold) 이상 하락하면 하락장 진입으로 판단 + if (peakPrice > 0 && ((currentClose - peakPrice) / peakPrice * 100) <= -dropThreshold) { + isDropping = true + bottomPrice = currentLow + bottomIndex = i // 바닥(최저점) 후보 인덱스 기록 + } + } else { + // 2. 하락 구간: 바닥 갱신 확인 + if (currentLow < bottomPrice) { + bottomPrice = currentLow + bottomIndex = i + } + // 바닥 대비 특정 비율(reboundThreshold) 이상 상승하면 반등 완료로 판단 + if (bottomPrice > 0 && ((currentClose - bottomPrice) / bottomPrice * 100) >= reboundThreshold) { + val daysToRebound = i - bottomIndex // 바닥을 찍고 반등하기까지 걸린 캔들 수 + reboundTerms.add(daysToRebound) + + // 3. 상태 초기화 (다음 하락/반등 사이클을 찾기 위해) + isDropping = false + peakPrice = currentHigh + } + } + } + + // 반등 사이클이 한 번이라도 있었다면 평균 캔들 수를 반환 + return if (reboundTerms.isNotEmpty()) reboundTerms.average() else 0.0 + } + /** * 억울한 HOLD를 막아주는 유연한 과열 판별 로직 */ @@ -157,6 +311,51 @@ class TechnicalAnalyzer { } return trList.average() } + /** + * [신규] 현재 주가가 통계적 반등 주기에 근접했는지 확인합니다. + * @param candles 분석할 캔들 리스트 (daily, weekly 등) + * @param avgReboundTerm 앞서 계산한 평균 반등 소요 캔들 수 + * @param dropThreshold 하락장으로 판단할 기준 하락률 (기본 5.0%) + * @param timeTolerance 오차 허용 범위 (기본 1.5 -> 평균 주기보다 하루이틀 빠르거나 늦어도 인정) + */ + fun checkReboundApproaching( + candles: List, + avgReboundTerm: Double, + dropThreshold: Double = 5.0, + timeTolerance: Double = 1.5 + ): Boolean { + if (candles.size < 20 || avgReboundTerm <= 0.0) return false + + // 1. 최근 20일 내 단기 고점 파악 + val recentCandles = candles.takeLast(20) + var recentPeakPrice = 0.0 + var daysSincePeak = 0 + + for (i in recentCandles.indices.reversed()) { + val highPrice = recentCandles[i].stck_hgpr.toDouble() + if (highPrice > recentPeakPrice) { + recentPeakPrice = highPrice + daysSincePeak = (recentCandles.size - 1) - i + } + } + + val currentDropRate = (candles.last().stck_prpr.toDouble() - recentPeakPrice) / recentPeakPrice * 100 + + // 🌟 2. 3가지 핵심 조건 분리 + val isPriceDropped = currentDropRate <= -dropThreshold + // 조건 A: 가격이 통계적 하락폭만큼 충분히 빠졌는가? + + val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance) + // 조건 B: 반등 '최소' 기간을 채웠는가? (떨어지는 칼날을 너무 일찍 잡는 것 방지) + + val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2)) + // 조건 C: 반등 '최대' 기간을 넘기지 않았는가? (죽은 주식처럼 너무 오래 횡보하는 것 방지) + + // 🌟 3. 3개 중 2개 이상 만족 시 반등 임박(Approaching)으로 판단 + val passedConditions = listOf(isPriceDropped, isPastMinTime, isWithinMaxTime).count { it } + + return passedConditions >= 2 + } fun calculateMFI(candles: List, period: Int = 14): Double { if (candles.size < period + 1) return 50.0 @@ -241,4 +440,191 @@ $standardizedScores - RSI (Daily): ${"%.1f".format(calculateRSI(daily))} """.trimIndent() } -} \ No newline at end of file + + /** + * 종목의 과거 차트를 분석하여 고유의 반등 통계(평균 주기, 오차 범위, 평균 하락폭)를 도출합니다. + */ + fun calculateDynamicReboundStats( + candles: List, + minDropToDetect: Double = 3.0 + ): ReboundStats { + if (candles.size < 20) return ReboundStats() + + var peakPrice = candles.first().stck_hgpr.toDouble() + var bottomPrice = peakPrice + var bottomIndex = 0 + var isDropping = false + + val reboundTerms = mutableListOf() + val dropRates = mutableListOf() + val reboundAmplitudes = mutableListOf() // 🌟 [신규] 반등 상승폭 수집 + + for (i in candles.indices) { + val currentHigh = candles[i].stck_hgpr.toDouble() + val currentLow = candles[i].stck_lwpr.toDouble() + val currentClose = candles[i].stck_prpr.toDouble() + + if (!isDropping) { + if (currentHigh > peakPrice) peakPrice = currentHigh + val dropRate = if (peakPrice > 0) ((currentClose - peakPrice) / peakPrice * 100) else 0.0 + + if (dropRate <= -minDropToDetect) { + isDropping = true + bottomPrice = currentLow + bottomIndex = i + } + } else { + if (currentLow < bottomPrice) { + bottomPrice = currentLow + bottomIndex = i + } + + val reboundRate = if (bottomPrice > 0) ((currentClose - bottomPrice) / bottomPrice * 100) else 0.0 + if (reboundRate >= 3.0) { // 3% 이상 반등 시 사이클 종료 및 기록 + reboundTerms.add(i - bottomIndex) + dropRates.add(abs((bottomPrice - peakPrice) / peakPrice * 100)) + reboundAmplitudes.add(reboundRate) // 🌟 상승폭 기록 + + isDropping = false + peakPrice = currentHigh + } + } + } + + if (reboundTerms.size >= 2) { + val avgDays = reboundTerms.average() + val variance = reboundTerms.map { Math.pow(it - avgDays, 2.0) }.average() + val safeTolerance = Math.sqrt(variance).coerceIn(1.0, 3.0) + + return ReboundStats( + avgReboundPeriod = avgDays, + timeTolerance = safeTolerance, + avgDropRate = dropRates.average(), + avgReboundAmplitude = reboundAmplitudes.average(), // 🌟 평균 반등폭 반환 + isValid = true + ) + } + return ReboundStats() + } + + fun generateMTFReboundGuide( + targetProfitRate: Double // 시스템 설정에 있는 목표 수익률 (예: 3.0%) + ): String { + // 1. 월, 주, 일봉 통계 추출 + val monthlyStats = calculateDynamicReboundStats(monthly, minDropToDetect = 10.0) + val weeklyStats = calculateDynamicReboundStats(weekly, minDropToDetect = 5.0) + val dailyStats = calculateDynamicReboundStats(daily, minDropToDetect = 3.0) + + val guideBuilder = java.lang.StringBuilder() + var isVeryFavorable = false + + // 2. 가장 신뢰도 높은 '주봉(Weekly)' 기준으로 수익률 보정 평가 + if (weeklyStats.isValid) { + // 과거 평균 반등폭이 내 목표 수익률의 1.5배 이상이라면? -> "안전 마진 확보(유리함)" + if (weeklyStats.avgReboundAmplitude >= targetProfitRate * 1.5) { + isVeryFavorable = true + guideBuilder.append("🔥 [프리미엄 타점] 과거 평균 반등폭(${"%.1f".format(weeklyStats.avgReboundAmplitude)}%)이 목표수익률을 크게 상회합니다. 타점을 조금 더 관대하게 잡습니다.\n") + } + + // 유리한 조건이면 오차 허용 범위를 넓혀서 예측일에 조금 더 일찍 진입할 수 있게 보정 + val adjustedTolerance = if (isVeryFavorable) weeklyStats.timeTolerance * 1.5 else weeklyStats.timeTolerance + + guideBuilder.append("- 주간(W): 평균 ${"%.1f".format(weeklyStats.avgReboundPeriod)}주 조정 후 반등 (오차 ±${"%.1f".format(adjustedTolerance)}주)\n") + } + + // 3. 일봉 및 월봉 코멘트 추가 + if (dailyStats.isValid) { + val dailyAdjTolerance = if (isVeryFavorable) dailyStats.timeTolerance * 1.5 else dailyStats.timeTolerance + guideBuilder.append("- 일간(D): 단기 평균 ${"%.1f".format(dailyStats.avgReboundPeriod)}일 조정 후 반등 (오차 ±${"%.1f".format(dailyAdjTolerance)}일)\n") + } + + if (monthlyStats.isValid) { + guideBuilder.append("- 월간(M): 장기 사이클 평균 ${"%.1f".format(monthlyStats.avgReboundPeriod)}개월\n") + } + + if (guideBuilder.isEmpty()) { + return "명확한 MTF(다중 타임프레임) 반등 패턴이 없습니다." + } + + return guideBuilder.toString() + } + + /** + * [신규] 종목이 과열되지 않고 안정적으로 우상향 추세를 타고 있는지 확인합니다. + */ + fun checkSteadyUptrend(candles: List): Boolean { + if (candles.size < 20) return false + + val currentPrice = candles.last().stck_prpr.toDouble() + + // 1. 이동평균선 계산 (5일, 20일) + val ma5 = candles.takeLast(5).map { it.stck_prpr.toDouble() }.average() + val ma20 = candles.takeLast(20).map { it.stck_prpr.toDouble() }.average() + + // 5일 전의 20일 이평선 (20일선 자체가 위로 고개를 들고 있는지 확인) + val pastMa20 = candles.dropLast(5).takeLast(20).map { it.stck_prpr.toDouble() }.average() + + // 2. 정배열 및 추세 확인 (현재가 > 5일선 > 20일선) + val isTrendAligned = currentPrice > ma5 && ma5 > ma20 + val isMa20Rising = ma20 > pastMa20 + + // 3. 이격도 과열 방지 (20일선 대비 너무 높게 떠 있으면 추격 매수 금지) + // 기존에 만드신 isOverheatedStock()을 재활용하거나, 여기서 타이트하게 110% 등으로 제어합니다. + val disparity20 = (currentPrice / ma20) * 100 + val isNotTooHigh = disparity20 <= 110.0 // 20일선 대비 10% 이내에 있을 때만 안전한 눌림/우상향으로 인정 + + // 🌟 정배열이고, 20일선이 상승 중이며, 너무 과열되지 않았을 때만 True + return isTrendAligned && isMa20Rising && isNotTooHigh && !isOverheatedStock() + } + + /** + * 최근 캔들의 등락률(변동성)을 기반으로 통계적인 다음 캔들의 가격 이동 범위를 예측합니다. + */ + fun calculateVolatilityForecast(candles: List, period: Int = 20): VolatilityForecast { + if (candles.size < period + 1) { + // 데이터가 부족하면 현재가 그대로 반환 + val cp = candles.lastOrNull()?.stck_prpr?.toDouble() ?: 0.0 + return VolatilityForecast(cp, cp, cp, cp) + } + + val currentPrice = candles.last().stck_prpr.toDouble() + val dailyReturns = mutableListOf() + + // 1. 최근 N일간의 등락률(%) 추출 + val subList = candles.takeLast(period + 1) + for (i in 1 until subList.size) { + val prevClose = subList[i-1].stck_prpr.toDouble() + val currClose = subList[i].stck_prpr.toDouble() + if (prevClose > 0) { + dailyReturns.add((currClose - prevClose) / prevClose) + } + } + + // 2. 등락률의 평균(Mean)과 표준편차(Volatility) 산출 + val meanReturn = dailyReturns.average() + val variance = dailyReturns.map { Math.pow(it - meanReturn, 2.0) }.average() + val stdDev = Math.sqrt(variance) + + // 3. 현재가에 통계적 변동성(Z-Score)을 곱하여 미래 가격 범위 예측 + val realisticHigh = currentPrice * (1 + meanReturn + stdDev) + val realisticLow = currentPrice * (1 + meanReturn - stdDev) + + val extremeHigh = currentPrice * (1 + meanReturn + (stdDev * 2)) + val extremeLow = currentPrice * (1 + meanReturn - (stdDev * 2)) + + return VolatilityForecast(realisticHigh, realisticLow, extremeHigh, extremeLow) + } +} +data class VolatilityForecast( + val realisticHigh: Double, // 1표준편차 상단 (현실적 목표가, 68% 확률 내) + val realisticLow: Double, // 1표준편차 하단 (현실적 지지선) + val extremeHigh: Double, // 2표준편차 상단 (오버슈팅 저항선, 95% 확률 내) + val extremeLow: Double // 2표준편차 하단 (투매 마지노선) +) +data class ReboundStats( + val avgReboundPeriod: Double = 0.0, // 평균 반등 소요 캔들 (일/주/월) + val timeTolerance: Double = 1.5, // 오차 허용 범위 (표준편차) + val avgDropRate: Double = 5.0, // 평균 하락폭 + val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가? + val isValid: Boolean = false +) \ No newline at end of file diff --git a/src/main/kotlin/model/TradeModels.kt b/src/main/kotlin/model/TradeModels.kt index 0f56605..b140b2e 100644 --- a/src/main/kotlin/model/TradeModels.kt +++ b/src/main/kotlin/model/TradeModels.kt @@ -43,6 +43,12 @@ class TradingDecision { var financialData : String? = null var analyzer : TechnicalAnalyzer? = null var signalModel : ScalpingSignalModel? = null + var maxRealisticProfitRate :Double = 0.0 + var reboundDaysDaily: Double = 0.0 // 일봉 기준 평균 반등 소요일 + + var reboundWeeksWeekly: Double = 0.0 // 주봉 기준 평균 반등 소요주 + var isReboundApproaching: Boolean = false // 반등 주기에 근접했는지 여부 + var reboundGuideMessage: String = "반등 주기 데이터 없음" // UI나 로그에 노출할 가이드 메시지 fun shortPossible() = listOf(ultraShortScore, @@ -60,7 +66,8 @@ class TradingDecision { longTermScore).average() - fun summary() : String{ + fun summary( + targetProfitRate: Double) : String{ return """ $corpName[$stockName] 수익실현 가능성 : ${profitPossible()} @@ -75,6 +82,8 @@ financialScore: $financialScore newsScore: $newsScore decision: $decision reason: $reason +예측가능 수익율 : ${maxRealisticProfitRate} +반등 주기 가이드: ${analyzer?.generateMTFReboundGuide(targetProfitRate)} """.trimIndent() } @@ -93,6 +102,7 @@ reason: $reason confidence: $confidence 기술 분석: $techSummary 뉴스 점수: $newsScore +반등 주기 가이드: $reboundGuideMessage """.trimIndent() } } diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 0b82aa2..e783fe9 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -237,8 +237,8 @@ object KisTradeService { isDomestic: Boolean = true ): Result> { val config = KisSession.config - val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" - else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice" + val path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + val today = LocalDate.now() val formatter = DateTimeFormatter.ofPattern("yyyyMMdd") @@ -247,7 +247,7 @@ object KisTradeService { // [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전) // 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다. val startDate = when (periodCode) { - "D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분 + "D" -> today.minusDays(90).format(formatter) // 일봉: 6개월치면 100개 충분 "W" -> today.minusYears(2).format(formatter) // 주봉: 2년치 "M" -> today.minusYears(8).format(formatter) // 월봉: 8년치 else -> today.minusYears(1).format(formatter) diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 53cef01..9be0f1d 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -331,6 +331,7 @@ object RagService { result(finalDecision, true) } catch (e: Exception) { + e.printStackTrace() println("❌ [$stockName] 분석 실패: ${e.message}") } } @@ -560,6 +561,7 @@ object RagService { println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms") return TradingDecision().apply { + this.analyzer = tempDecision.analyzer this.technicalScore = techScore100 this.financialScore = finScore100 this.systemScore = sysScore100 diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 19639a1..dfd0233 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -110,15 +110,33 @@ object AutoTradingManager { println("${decision.stockName} ${decision.decision}") // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 if (decision.decision == "BUY") { - + var maxRealisticProfitRate = 0.0 // AI가 이미 검증한 등급을 사용 (재계산 불필요) val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE + decision.analyzer?.let { a -> + val volatility = a?.calculateVolatilityForecast(a.daily, 20) + volatility?.let { + maxRealisticProfitRate = ((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0 + } + } +// 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준) + + +// 2. 시스템 기본 설정 수익률과 비교 + val baseProfitRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(grade.profitGuide) + +// 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤 + val finalProfitRate = if (maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate) { + max(maxRealisticProfitRate ,0.05) + } else { + baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행 + } // 2. 최종 매수 실행 val gradeRate = KisSession.config.getValues(grade.allocationRate) val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate - - TradingLogStore.addLog(decision,"BUY",decision.summary()) + decision.maxRealisticProfitRate = maxRealisticProfitRate + TradingLogStore.addLog(decision,"BUY",decision.summary(KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))) var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings()?.any { it.code.equals(decision.stockCode) && it.quantity.toInt() > 2 && it.availOrderCount.toInt() > 0} ?: false if (hasCodes == true) { TradingLogStore.addNotice(decision.stockName,decision.stockCode,"물타기 시도 1주 매수") @@ -127,7 +145,7 @@ object AutoTradingManager { excuteTrade( decision = decision, orderQty = calculatedQty.toString(), - profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide), + profitRate1 = finalProfitRate, investmentGrade = grade, hasCode = hasCodes == true ) @@ -278,9 +296,9 @@ object AutoTradingManager { reason = decision.reason ?: "", // AI 이유 decision = decision // AI 객체 통째로 전달 ) - - syncAndExecute(realOrderNo) - + if (!hasCode) { + syncAndExecute(realOrderNo) + } // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 TradingLogStore.addLog( decision, @@ -340,6 +358,7 @@ object AutoTradingManager { if (processingIds.contains(orderNo)) return processingIds.add(orderNo) + try { val dbItem = DatabaseFactory.findByOrderNo(orderNo) val execData = executionCache[orderNo] @@ -1070,7 +1089,8 @@ 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 == 1)) { + } else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || + (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 0)) { TradingLogStore.addAnalyzer( " - ", " - ", @@ -1141,12 +1161,37 @@ object AutoTradingManager { return@withTimeout } + // 🌟 [추가] 고도화된 사전 필터링 (검문소) + val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } + + // 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) + val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) + val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 + + // 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?) + val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData) + val isApproaching = tempAnalyzer.checkReboundApproaching( + candles = dailyData, + avgReboundTerm = dailyStats.avgReboundPeriod, + dropThreshold = dailyStats.avgDropRate * 0.8, + timeTolerance = dailyStats.timeTolerance + ) + val isSteadyUptrend = tempAnalyzer.checkSteadyUptrend(dailyData) + + // 🌟 [수정] 조건 통합 (OR 조건) + val isProfitable = expectedProfitRate >= 2.0 || dailyStats.avgReboundAmplitude >= 2.0 + + // 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과 + val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= 10.0 && dailyStats.avgReboundPeriod >= 1.5) || isSteadyUptrend + + + if (!isProfitable || !isValidEntryTiming) { + print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isApproaching) | ") + return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감 + } + println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})") if (!isSafetyBeltStockCodes.contains(stock.code)) { - - - - val analyzer = coroutineScope { val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } delay(20) @@ -1167,6 +1212,7 @@ object AutoTradingManager { } } if (analyzer.isValid()) { + println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})") RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess -> callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)