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() } /** * 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성 */ fun generateComprehensiveSignal(): ScalpingSignalModel { val scalpingAnalyzer = ScalpingAnalyzer() val dailyBullish = isDailyBullish() // 1. 기본 스캘핑 신호 생성 val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish) var refinedScore = baseSignal.compositeScore.toDouble() // 2. 점수 정교화 (가점/감점 요인) // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) { refinedScore += 10.0 } // [보완] 자금 유입 강도(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 // 🚀 [마이크로 분석] 기존 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) ) } /** * 억울한 HOLD를 막아주는 유연한 과열 판별 로직 */ fun isOverheatedStock(): Boolean { if (daily.size < 20) return false val currentPrice = daily.last().stck_prpr.toDouble() // 1. 일봉 20일선 이격도 val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val disparityDaily = (currentPrice / ma20Daily) * 100 // 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인) val rsiDaily = calculateRSI(daily) // 3. 초단기(최근 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 > 105.0 // 10분 평균가 대비 순간적으로 5% 이상 폭등 시 } // 단순히 이격도 115%라고 막는 것이 아니라 3가지 깐깐한 조건 중 하나라도 충족될 때만 과열 판정 return disparityDaily > 130.0 || // (A) 역대급 폭등 상태 (disparityDaily > 115.0 && rsiDaily > 75.0) || // (B) 급등 중이면서 일봉 과매수 (disparityDaily > 115.0 && isMicroOverheated) // (C) 급등 중이면서 10분 내 순간 펌핑(꼭지) } 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() } 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() } }