203 lines
8.0 KiB
Kotlin
203 lines
8.0 KiB
Kotlin
|
|
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 {
|
||
|
|
var monthly: List<CandleData> = emptyList()
|
||
|
|
var weekly: List<CandleData> = emptyList()
|
||
|
|
var daily: List<CandleData> = emptyList()
|
||
|
|
var min30: List<CandleData> = emptyList()
|
||
|
|
|
||
|
|
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
|
||
|
|
|
||
|
|
/**
|
||
|
|
* [신규] 기술적 지표와 추세를 결합한 종합 신호 생성
|
||
|
|
*/
|
||
|
|
fun generateComprehensiveSignal(): ScalpingSignalModel {
|
||
|
|
val scalpingAnalyzer = ScalpingAnalyzer()
|
||
|
|
val dailyBullish = isDailyBullish()
|
||
|
|
|
||
|
|
// 1. 기본 스캘핑 신호 생성
|
||
|
|
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
|
||
|
|
|
||
|
|
// 2. 점수 정교화 (가점/감점 요인)
|
||
|
|
var refinedScore = baseSignal.compositeScore.toDouble()
|
||
|
|
|
||
|
|
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
||
|
|
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
|
||
|
|
|
||
|
|
return baseSignal.copy(
|
||
|
|
compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(),
|
||
|
|
successProbPct = (refinedScore * 0.85).coerceAtMost(98.0)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
fun isOverheatedStock(): Boolean {
|
||
|
|
if (min30.size < 20 || daily.size < 20) return false
|
||
|
|
val currentPrice = min30.last().stck_prpr.toDouble()
|
||
|
|
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||
|
|
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||
|
|
|
||
|
|
// 이격도 115% 이상이면 주의, 125% 이상이면 과열
|
||
|
|
return disparityDaily > 115.0
|
||
|
|
}
|
||
|
|
|
||
|
|
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<CandleData>, period: Int = 14): Double {
|
||
|
|
if (candles.size < period + 1) return 0.0
|
||
|
|
val sub = candles.takeLast(period + 1)
|
||
|
|
val trList = mutableListOf<Double>()
|
||
|
|
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<CandleData>, 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<CandleData>): 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<CandleData>): 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<CandleData>): 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()
|
||
|
|
|
||
|
|
// [표준화된 분석 점수] - AI에게 가이드라인 제공
|
||
|
|
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()
|
||
|
|
|
||
|
|
// [시계열 가격 흐름] - AI에게 지지와 저항 맥락 제공
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
}
|