package analyzer import kotlinx.serialization.Serializable import model.CandleData import kotlin.math.abs import kotlin.text.toDouble import kotlin.text.toInt data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) val shortTerm: Int, // 단기 (일봉/뉴스) val midTerm: Int, // 중기 (주봉/재무) val longTerm: Int // 장기 (월봉/펀더멘털) ) { override fun toString(): String { return """ 평점 : ${avg()} 초단 : $ultraShort 단기 : $shortTerm 중기 : $midTerm 장기 : $longTerm """.trimIndent() } fun avg() = listOf(ultraShort, shortTerm, midTerm, longTerm).average() } @Serializable class TechnicalAnalyzer { // 주의: min30은 '30분봉'이 아니라 '1분 단위 캔들 30개'를 의미합니다. var min30: List = emptyList() var daily: List = emptyList() var weekly: List = emptyList() var monthly: List = emptyList() 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) 흐름을 결합한 종합 신호 생성 */ fun generateComprehensiveSignal(): ScalpingSignalModel { val scalpingAnalyzer = ScalpingAnalyzer() val dailyBullish = isDailyBullish() // 1. 기본 스캘핑 신호 생성 val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish) var refinedScore = baseSignal.compositeScore.toDouble() // 2. 점수 정교화 (가점/감점 요인) // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 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) 반영 val mfi = calculateMFI(min30) when { mfi > 80.0 -> refinedScore -= 15.0 // 과매수 권역 감점 mfi < 20.0 -> refinedScore -= 5.0 // 자금 유출 감점 mfi in 45.0..65.0 -> refinedScore += 5.0 // 안정적 수급 구간 } // [보완] 변동성 돌파 확인 (ATR 대비 현재 몸통 크기) val atr = calculateATR(min30) val lastCandle = min30.last() 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개 캔들) // ① 초단기 추세 가감점 (최근 5분간 변화율) val microChange = calculateChange(last5Candles) if (microChange > 1.5) refinedScore += 6.0 // 순간 급등세 (수급 유입) else if (microChange < -1.5) refinedScore -= 12.0 // 순간 투매 방어 (강력 감점) // ② 초단기 거래량 급증 (V-Spike) 확인 val recentVolume = last5Candles.map { it.cntg_vol.toDouble() }.average() val pastVolume = min30.dropLast(5).takeLast(10).map { it.cntg_vol.toDouble() }.average() // 그 이전 10분 평균 if (pastVolume > 0 && recentVolume > pastVolume * 2.5) { // 평소보다 거래량이 2.5배 터졌을 때, 양봉이면 매수세 폭발, 음봉이면 쏟아내는 투매 물량 val isBullishMicro = lastCandle.stck_prpr.toDouble() >= lastCandle.stck_oprc.toDouble() if (isBullishMicro) refinedScore += 8.0 else refinedScore -= 10.0 } // ③ 초단기 RSI 과열/과매도 필터링 val rsiMicro = calculateRSI(last5Candles) if (rsiMicro > 75.0) refinedScore -= 8.0 // 초단기 꼭지 (추격매수 방지) else if (rsiMicro < 25.0) refinedScore += 5.0 // 초단기 투매 과도 (기술적 반등 타점 노림) } return baseSignal.copy( compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(), successProbPct = (refinedScore * 0.85).coerceAtMost(98.0) ) } /** * [신규] 종목의 평균 반등 텀(캔들 수)을 계산합니다. * * @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를 막아주는 유연한 과열 판별 로직 (주가 및 변동성 기반 세분화) */ fun isOverheatedStock(): Boolean { if (daily.size < 20) return false val currentPrice = daily.last().stck_prpr.toDouble() // 1. 기본 지표 계산 val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val disparityDaily = (currentPrice / ma20Daily) * 100 val rsiDaily = calculateRSI(daily) // 2. 가격대별(시가총액 및 호가단위 대용) 동적 임계값 세팅 val maxDisparity: Double // 절대 진입 금지 (초과열) val warningDisparity: Double // 경고 수준 (보조지표 결합 시 차단) val microPumpThreshold: Double // 10분 내 단기 펌핑 기준 when { currentPrice >= 50000.0 -> { // [대형주/우량주] 무거워서 20일선 대비 15%만 떠도 역사적 과열 maxDisparity = 115.0 warningDisparity = 110.0 microPumpThreshold = 103.0 // 10분 만에 3% 급등하면 꼭지 } currentPrice in 5000.0..49999.0 -> { // [일반 중소형주] 표준적인 변동성 maxDisparity = 125.0 warningDisparity = 115.0 microPumpThreshold = 105.0 // 10분 만에 5% 급등 } else -> { // [소형주/동전주] 가벼워서 상한가 한 방에 130% 쉽게 도달 maxDisparity = 135.0 warningDisparity = 120.0 microPumpThreshold = 107.0 // 10분 만에 7% 급등 } } // 3. 종목 고유 변동성(ATR)을 통한 임계값 미세 보정 (Smart Adjustment) // 평소 하루에 2~3% 움직이는 얌전한 주식이 갑자기 튀면 임계값을 더 빡빡하게 죔 val atrPct = (calculateATR(daily) / currentPrice) * 100.0 val adjustedMaxDisparity = if (atrPct < 3.0) maxDisparity * 0.96 else maxDisparity val adjustedWarnDisparity = if (atrPct < 3.0) warningDisparity * 0.97 else warningDisparity // 4. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용) var isMicroOverheated = false if (min30.size >= 10) { val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average() val disparityMin = (currentPrice / ma10Min) * 100 isMicroOverheated = disparityMin > microPumpThreshold } // 5. 최종 세분화 판정 로직 return disparityDaily > adjustedMaxDisparity || // (A) 종목 체급 대비 역대급 폭등 상태 (disparityDaily > adjustedWarnDisparity && rsiDaily > 75.0) || // (B) 체급별 경고 수준 + 일봉 과매수 (disparityDaily > adjustedWarnDisparity && isMicroOverheated) // (C) 체급별 경고 수준 + 초단기 펌핑(꼭지) } fun calculateScores(financialScore100: Int): InvestmentScores { val signal = generateComprehensiveSignal() // 이미 100점 만점 기반 // 모든 지표를 100점 스케일 내에서 조합 val ultra = signal.compositeScore val short = (calculateRSI(daily) * 0.5 + (if(calculateOBV(daily) > 0) 50 else 0)).toInt() val mid = (if(calculateChange(weekly) > 0) 60 else 20) + (financialScore100 * 0.4).toInt() val long = (if(calculateChange(monthly) > 0) 50 else 10) + (financialScore100 * 0.5).toInt() return InvestmentScores( ultraShort = ultra.coerceIn(0, 100), shortTerm = short.coerceIn(0, 100), midTerm = mid.coerceIn(0, 100), longTerm = long.coerceIn(0, 100) ) } // --- 이하 유틸리티 함수군 (변경 없음) --- fun calculateATR(candles: List, period: Int = 14): Double { if (candles.size < period + 1) return 0.0 val sub = candles.takeLast(period + 1) val trList = mutableListOf() for (i in 1 until sub.size) { val high = sub[i].stck_hgpr.toDouble() val low = sub[i].stck_lwpr.toDouble() val prevClose = sub[i - 1].stck_prpr.toDouble() trList.add(maxOf(high - low, abs(high - prevClose), abs(low - prevClose))) } 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 = 2.0 ): 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 val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance) // 최대 기간 조건은 참고용으로 남겨두되 매수 차단 로직에서는 제외합니다. // val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2)) // 🌟 3. 현실적인 타점 판별 (필수 2가지만 강력하게 요구) // 필수 1: 가격이 통계적 하락폭만큼 충분히 빠졌는가? (눌림목 대전제) // 필수 2: 최소한의 반등 준비 기간(평균 기간 - 오차)은 지났는가? (떨어지는 칼날 방지) return isPriceDropped && isPastMinTime } fun calculateMFI(candles: List, period: Int = 14): Double { if (candles.size < period + 1) return 50.0 val subList = candles.takeLast(period + 1) var posFlow = 0.0 var negFlow = 0.0 for (i in 1 until subList.size) { val prevTyp = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3 val currTyp = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3 val flow = currTyp * subList[i].cntg_vol.toDouble() if (currTyp > prevTyp) posFlow += flow else if (currTyp < prevTyp) negFlow += flow } return if (negFlow == 0.0) 100.0 else 100 - (100 / (1 + (posFlow / negFlow))) } fun calculateRSI(list: List): Double { if (list.size < 2) return 50.0 var gains = 0.0 var losses = 0.0 for (i in 1 until list.size) { val diff = list[i].stck_prpr.toDouble() - list[i-1].stck_prpr.toDouble() if (diff > 0) gains += diff else losses -= diff } return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100 } fun calculateOBV(candles: List): Double { var obv = 0.0 for (i in 1 until candles.size) { val prevClose = candles[i-1].stck_prpr.toDouble() val currClose = candles[i].stck_prpr.toDouble() if (currClose > prevClose) obv += candles[i].cntg_vol.toDouble() else if (currClose < prevClose) obv -= candles[i].cntg_vol.toDouble() } return obv } fun calculateChange(list: List): Double { if (list.isEmpty()) return 0.0 val start = list.first().stck_oprc.toDouble() val end = list.last().stck_prpr.toDouble() return if (start != 0.0) ((end - start) / start) * 100 else 0.0 } fun isDailyBullish(): Boolean { if (daily.size < 20) return true val currentPrice = daily.last().stck_prpr.toDouble() val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average() val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average() return currentPrice > ma20 && ma5 > prevMa5 } fun generateComprehensiveReport(finScore100: Int): String { val signal = generateComprehensiveSignal() val currentPrice = min30.last().stck_prpr.toDouble() // [표준화된 분석 점수] val standardizedScores = """ - Financial Health Score: $finScore100 / 100 - Technical Momentum Score: ${signal.compositeScore} / 100 - Market Energy (Volume): ${"%.1f".format(signal.volRatio)}x relative to avg """.trimIndent() // [시계열 가격 흐름] val monthlyRange = monthly.takeLast(3).joinToString(" -> ") { "[H:${it.stck_hgpr}, L:${it.stck_lwpr}]" } val weeklyRange = weekly.takeLast(4).joinToString(" -> ") { "[H:${it.stck_hgpr}, L:${it.stck_lwpr}]" } return """ # [Standardized Analysis Summary] $standardizedScores # [Historical Price Range] - Monthly (Last 3M): $monthlyRange - Weekly (Last 4W): $weeklyRange - Current Price: $currentPrice # [Technical Context] - Base Position: ${"%.1f".format((currentPrice / daily.takeLast(120).map { it.stck_prpr.toDouble() }.average()) * 100)}% (120MA) - RSI (Daily): ${"%.1f".format(calculateRSI(daily))} """.trimIndent() } /** * 종목의 과거 차트를 분석하여 고유의 반등 통계(평균 주기, 오차 범위, 평균 하락폭)를 도출합니다. */ 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 >= minDropToDetect) { // 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) } /** * [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다. */ /** * [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다. * ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다. */ /** * [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다. */ fun predictDropBottom( candles: List, reboundStats: ReboundStats, volatility: VolatilityForecast, currentAtr: Double ): DropPrediction? { if (candles.size < 20 || !reboundStats.isValid) return null // 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외) val recentCandles = candles.takeLast((candles.size.times(0.8).toInt())) // 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 (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() // 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정 // 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦 val truePeak = smoothedPeak - (currentAtr * 0.3) // 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산 val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0 // 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출 val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0)) val remainingDropRate = reboundStats.avgDropRate - currentDropRate // 바닥권 인정 마진 (ATR 기반) //B. 바닥권 판정 마진predictDropBottom currentAtr * 0.7 여유 마진 마진을 축소(currentAtr * 0.3)하여 예상 바닥에 더 근접해야 인정 val bottomMargin = currentAtr * 0.6 // 저점 평균(smoothedBottom)도 마지노선 계산에 추가 가중치로 섞어줌으로써 저점에 더 밀착시킴 val adjustedExtremeLow = (volatility.extremeLow + smoothedBottom) / 2.0 val isBottomZone = currentPrice <= (expectedBottomPrice + bottomMargin) || currentPrice <= (adjustedExtremeLow + bottomMargin) return DropPrediction( recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달 expectedBottomPrice = expectedBottomPrice, 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( 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표준편차 하단 (현실적 지지선) val extremeHigh: Double, // 2표준편차 상단 (오버슈팅 저항선, 95% 확률 내) val extremeLow: Double // 2표준편차 하단 (투매 마지노선) ) data class ReboundStats( val avgReboundPeriod: Double = 0.0, // 평균 반등 소요 캔들 (일/주/월) val timeTolerance: Double = 2.0, // 오차 허용 범위 (표준편차) val avgDropRate: Double = 5.0, // 평균 하락폭 val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가? val isValid: Boolean = false )