빨라져라
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
package analyzer
|
||||
|
||||
import kotlin.compareTo
|
||||
|
||||
|
||||
object FinancialAnalyzer {
|
||||
|
||||
/**
|
||||
* [매수 고려] 우량 기업 요건 확인
|
||||
* 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다.
|
||||
*/
|
||||
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
|
||||
// 1. 유동성 위기 체크 (이건 유지하는 것이 좋습니다)
|
||||
val isDebtSafe = fs.debtRatio < 300.0 // 200% -> 300%로 완화
|
||||
val isLiquiditySafe = fs.quickRatio > 60.0 // 80% -> 60%로 완화 (급전이 필요한 수준만 차단)
|
||||
|
||||
// 2. 턴어라운드 허용 (적자여도 개선 중이면 통과)
|
||||
val isTurningAround = !fs.isNetIncomePositive && fs.operatingProfitGrowth > 50.0
|
||||
val isNotFatalDeficit = fs.isNetIncomePositive || isTurningAround
|
||||
|
||||
// 3. 상장폐지 요건 중심 필터
|
||||
val isNotCapitalImpaired = fs.capitalImpairmentRate < 50.0 // 자본잠식 50% 이상 차단
|
||||
val isNotLossExploding = fs.lossToSalesRatio < 150.0 // 매출보다 손실이 너무 크면 차단
|
||||
|
||||
// 최종: 정말 위험한 경우가 아니면 분석 단계(AI/뉴스)로 보냄
|
||||
return isDebtSafe && isLiquiditySafe && isNotFatalDeficit && isNotCapitalImpaired
|
||||
}
|
||||
|
||||
fun toString(fs : FinancialStatement): String {
|
||||
var buffer = StringBuffer()
|
||||
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
|
||||
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
|
||||
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
|
||||
val isNotCrashing = fs.netIncomeGrowth > -40.0
|
||||
if ((isDebtSafe && isLiquiditySafe && isNotDeficit) == false) {
|
||||
if (!isDebtSafe)buffer.appendLine( "부채비율 200% 이상")
|
||||
if (!isLiquiditySafe)buffer.appendLine( "당좌비율 80% 미만")
|
||||
if (!isNotDeficit)buffer.appendLine( "당기순이익 적자")
|
||||
if (!isNotCrashing) { buffer.appendLine("당기순이익 급감(${String.format("%.1f", fs.netIncomeGrowth)}%)") }
|
||||
buffer.appendLine("최소 기준 미달")
|
||||
} else {
|
||||
buffer.appendLine("최소 기준 충족")
|
||||
}
|
||||
|
||||
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
|
||||
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
|
||||
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
|
||||
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
|
||||
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
|
||||
|
||||
if ((highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy) == false) {
|
||||
if(!highProfitability) buffer.appendLine( "ROE 10% 미만")
|
||||
if(!strongGrowth) buffer.appendLine( "이익 성장률 15% 미만")
|
||||
if(!verySafeDebt) buffer.appendLine( "부채비율 100% 이상 (안전성 미달)")
|
||||
if(!goodLiquidity) buffer.appendLine( "당좌비율 120% 이하 (여유 없음)")
|
||||
if(!businessHealthy) buffer.appendLine( "본업(영업이익)이 적자")
|
||||
buffer.appendLine("재무 건전성 및 성장성 미달")
|
||||
} else {
|
||||
buffer.appendLine("재무 건전성 및 성장성 충족")
|
||||
}
|
||||
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
fun calculateScore(fs: FinancialStatement): Int {
|
||||
var score = 50.0 // 중립 시작
|
||||
|
||||
// 1. 수익성 및 성장 추세 (Max 50)
|
||||
score += when {
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 20.0 -> 30.0 // 우량 성장
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -30.0 -> -20.0 // 쇠퇴 위험
|
||||
!fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 50.0 -> 20.0 // 턴어라운드 신호
|
||||
!fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -20.0 -> -30.0 // 적자 심화
|
||||
else -> 0.0
|
||||
}
|
||||
|
||||
// 2. 수익 효율 ROE (Max 30)
|
||||
score += (fs.roe.coerceIn(-20.0, 20.0) * 1.5)
|
||||
|
||||
// 3. 안정성 (Max 20)
|
||||
if (fs.debtRatio <= 100.0) score += 20.0
|
||||
else if (fs.debtRatio <= 150.0) score += 10.0
|
||||
|
||||
// 4. 감점 페널티 (위험 징후 시 최대 -50)
|
||||
if (fs.capitalImpairmentRate > 20.0) score -= 30.0
|
||||
if (fs.debtAccelerationRate > 100.0) score -= 20.0
|
||||
|
||||
return score.coerceIn(0.0, 100.0).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 상황별 상태 메시지 정교화
|
||||
*/
|
||||
fun getInvestmentStatus(fs: FinancialStatement): String {
|
||||
return when {
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 10.0 -> "🚀 [성장중] 실적 개선세 뚜렷"
|
||||
!fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 40.0 -> "☀️ [회복중] 적자폭 급감, 턴어라운드 가시화"
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -40.0 -> "⚠️ [쇠퇴중] 이익 급감, 적자전환 유의"
|
||||
else -> "🚨 [부실] 재무 구조 악화 지속"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object FinancialAnalyzer2 {
|
||||
|
||||
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
|
||||
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
|
||||
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
|
||||
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
|
||||
val isNotCrashing = fs.netIncomeGrowth > -40.0
|
||||
return isDebtSafe && isLiquiditySafe && isNotDeficit && isNotCrashing
|
||||
}
|
||||
|
||||
/**
|
||||
* [매수 고려] 우량 기업 요건 확인
|
||||
* 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다.
|
||||
*/
|
||||
fun isBuyConsiderationMet(fs: FinancialStatement): Boolean {
|
||||
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
|
||||
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
|
||||
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
|
||||
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
|
||||
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
|
||||
|
||||
return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy
|
||||
}
|
||||
|
||||
|
||||
fun toString(fs : FinancialStatement): String {
|
||||
var buffer = StringBuffer()
|
||||
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
|
||||
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
|
||||
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
|
||||
val isNotCrashing = fs.netIncomeGrowth > -40.0
|
||||
if ((isDebtSafe && isLiquiditySafe && isNotDeficit) == false) {
|
||||
if (!isDebtSafe)buffer.appendLine( "부채비율 200% 이상")
|
||||
if (!isLiquiditySafe)buffer.appendLine( "당좌비율 80% 미만")
|
||||
if (!isNotDeficit)buffer.appendLine( "당기순이익 적자")
|
||||
if (!isNotCrashing) { buffer.appendLine("당기순이익 급감(${String.format("%.1f", fs.netIncomeGrowth)}%)") }
|
||||
buffer.appendLine("최소 기준 미달")
|
||||
} else {
|
||||
buffer.appendLine("최소 기준 충족")
|
||||
}
|
||||
|
||||
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
|
||||
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
|
||||
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
|
||||
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
|
||||
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
|
||||
|
||||
if ((highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy) == false) {
|
||||
if(!highProfitability) buffer.appendLine( "ROE 10% 미만")
|
||||
if(!strongGrowth) buffer.appendLine( "이익 성장률 15% 미만")
|
||||
if(!verySafeDebt) buffer.appendLine( "부채비율 100% 이상 (안전성 미달)")
|
||||
if(!goodLiquidity) buffer.appendLine( "당좌비율 120% 이하 (여유 없음)")
|
||||
if(!businessHealthy) buffer.appendLine( "본업(영업이익)이 적자")
|
||||
buffer.appendLine("재무 건전성 및 성장성 미달")
|
||||
} else {
|
||||
buffer.appendLine("재무 건전성 및 성장성 충족")
|
||||
}
|
||||
|
||||
return buffer.toString()
|
||||
}
|
||||
/**
|
||||
* 종합 상태 반환 (UI 또는 로그용)
|
||||
*/
|
||||
fun getInvestmentStatus(fs: FinancialStatement): String {
|
||||
return when {
|
||||
isBuyConsiderationMet(fs) -> "🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수"
|
||||
isSafetyBeltMet(fs) -> "⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족"
|
||||
else -> "🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업"
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateScore(fs: FinancialStatement): Int {
|
||||
var score = 50.0 // 기본 점수
|
||||
|
||||
// 성장성 (영업이익 증가율)
|
||||
score += when {
|
||||
fs.operatingProfitGrowth > 20 -> 20
|
||||
fs.operatingProfitGrowth > 0 -> 10
|
||||
else -> -10 // 역성장 시 감점
|
||||
}
|
||||
|
||||
// 수익성 (ROE)
|
||||
score += when {
|
||||
fs.roe > 15 -> 15
|
||||
fs.roe > 5 -> 5
|
||||
fs.roe < 0 -> -15 // 적자 시 큰 감점
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// 안정성 (부채비율)
|
||||
score += when {
|
||||
fs.debtRatio < 100 -> 15
|
||||
fs.debtRatio < 200 -> 5
|
||||
else -> -10
|
||||
}
|
||||
|
||||
// 유동성 (당좌비율)
|
||||
if (fs.quickRatio < 100) score -= 10 // 단기 채무 지급 능력 부족 시 감점
|
||||
|
||||
return score.coerceIn(0.0, 100.0).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package analyzer
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.Double
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FinancialStatement(
|
||||
val revenueGrowth: Double = 0.0, // 매출액 증가율
|
||||
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
|
||||
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
|
||||
val roe: Double = 0.0, // ROE
|
||||
val debtRatio: Double = 0.0, // 부채비율
|
||||
val quickRatio: Double = 0.0, // 당좌비율
|
||||
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
|
||||
val isNetIncomePositive: Boolean = false,
|
||||
val capitalImpairmentRate: Double = 0.0,
|
||||
val debtAccelerationRate: Double = 0.0,
|
||||
val lossToSalesRatio: Double = 0.0
|
||||
)
|
||||
|
||||
object FinancialMapper {
|
||||
fun mapRawTextToStatement(rawText: String): FinancialStatement {
|
||||
if (rawText.isBlank()) return FinancialStatement()
|
||||
|
||||
val current = extractYearlyValues(rawText, "당기")
|
||||
val previous = extractYearlyValues(rawText, "전기")
|
||||
|
||||
// 기본 수치 추출
|
||||
val opCurrent = current["영업이익"] ?: 0.0
|
||||
val opPrevious = previous["영업이익"] ?: 0.0
|
||||
val salesCurrent = current["매출액"] ?: 1.0
|
||||
val niCurrent = current["당기순이익(손실)"] ?: 0.0
|
||||
val equityCurrent = current["자본총계"] ?: 1.0
|
||||
val capitalStock = current["자본금"] ?: 1.0
|
||||
val debtCurrent = current["부채총계"] ?: 0.0
|
||||
val debtPrevious = previous["부채총계"] ?: 1.0
|
||||
val currentAssets = current["유동자산"] ?: 0.0
|
||||
val currentLiabilities = current["유동부채"] ?: 1.0
|
||||
|
||||
// [강화] 자본잠식률: (자본금 - 자본총계) / 자본금
|
||||
val capitalImpairment = (capitalStock - equityCurrent) / capitalStock * 100
|
||||
|
||||
// [강화] 부채 가속도: 전년 대비 부채 증가율
|
||||
val debtAcceleration = ((debtCurrent - debtPrevious) / debtPrevious) * 100
|
||||
|
||||
// [강화] 매출 대비 영업손실률
|
||||
val lossToSalesRatio = if (opCurrent < 0) (abs(opCurrent) / salesCurrent) * 100 else 0.0
|
||||
|
||||
return FinancialStatement(
|
||||
operatingProfitGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / abs(opPrevious)) * 100 else 0.0,
|
||||
roe = (niCurrent / equityCurrent) * 100,
|
||||
debtRatio = (debtCurrent / equityCurrent) * 100,
|
||||
quickRatio = (currentAssets / currentLiabilities) * 100,
|
||||
isOperatingProfitPositive = opCurrent > 0,
|
||||
isNetIncomePositive = niCurrent > 0,
|
||||
// 추가된 정교화 지표
|
||||
capitalImpairmentRate = capitalImpairment,
|
||||
debtAccelerationRate = debtAcceleration,
|
||||
lossToSalesRatio = lossToSalesRatio
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
|
||||
val result = mutableMapOf<String, Double>()
|
||||
val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""")
|
||||
regex.findAll(text).forEach { match ->
|
||||
val key = match.groupValues[1].trim()
|
||||
val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
result[key] = rawValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package analyzer
|
||||
|
||||
import model.CandleData
|
||||
import service.Candle
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.*
|
||||
|
||||
data class ScalpingSignalModel(
|
||||
val currentPrice: Double,
|
||||
val buySignal: Boolean,
|
||||
val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수)
|
||||
val successProbPct: Double, // 성공 확률 추정 %
|
||||
val riskLevel: String, // "Low", "Medium", "High"
|
||||
val rsi: Double,
|
||||
val volRatio: Double,
|
||||
val suggestedSlPrice: Double, // 손절 가격
|
||||
val suggestedTpPrice: Double, // 익절 가격
|
||||
val riskRewardRatio: Double
|
||||
)
|
||||
|
||||
class ScalpingAnalyzer {
|
||||
companion object {
|
||||
private const val SMA_SHORT = 10 // 단기 이평선 (10봉)
|
||||
private const val SMA_LONG = 20 // 장기 이평선 (20봉)
|
||||
private const val RSI_WINDOW = 14 // RSI 기간
|
||||
private const val VOL_WINDOW = 20 // 거래량 평균 기간
|
||||
private const val VOL_SURGE_THRESHOLD = 1.5 // 평소 대비 1.5배 거래량
|
||||
private const val RSI_THRESHOLD = 50.0 // RSI 매수 우위 기준
|
||||
private const val DEFAULT_SL_PCT = -1.5 // 기본 손절 라인 (-1.5%)
|
||||
private const val DEFAULT_TP_PCT = 1.5 // 기본 익절 라인 (+1.5%)
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 분봉 데이터를 분석하여 종합 신호 모델을 반환합니다.
|
||||
*/
|
||||
fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
|
||||
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 이상의 데이터가 필요합니다.")
|
||||
|
||||
val closes = candles.map { it.close }
|
||||
val volumes = candles.map { it.volume }
|
||||
|
||||
// 1. 보조 지표 계산
|
||||
val sma10 = simpleMovingAverage(closes, SMA_SHORT)
|
||||
val sma20 = simpleMovingAverage(closes, SMA_LONG)
|
||||
val rsiList = computeRSI(closes)
|
||||
val volAvg = simpleMovingAverage(volumes, VOL_WINDOW)
|
||||
val (bbUpper, _, bbLower) = bollingerBands(closes)
|
||||
|
||||
// 2. 현재 시점 데이터 추출
|
||||
val current = candles.last()
|
||||
val currentClose = current.close
|
||||
val sma10Now = sma10.lastOrNull() ?: 0.0
|
||||
val sma20Now = sma20.lastOrNull() ?: 0.0
|
||||
val rsiNow = rsiList.lastOrNull() ?: 0.0
|
||||
val volRatioNow = if (volAvg.isNotEmpty()) current.volume / volAvg.last() else 1.0
|
||||
|
||||
// 3. 정교화된 상태 판별 로직
|
||||
// [볼린저 밴드 위치] 0.0(하단) ~ 1.0(상단)
|
||||
val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) {
|
||||
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
|
||||
} else 0.5
|
||||
|
||||
// [전고점 돌파 확인] 최근 6봉 이내의 최고점을 거래량과 함께 돌파하는지 확인
|
||||
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
|
||||
val isBreakout = currentClose > nearHigh && volRatioNow > 2.0 // 거래량 2배 동반 돌파
|
||||
|
||||
// [일봉 이격도 과열 체크] 5일 이동평균선 대비 이격도 계산 (상위 호출부에서 계산 권장)
|
||||
// 여기서는 기술적 지표 조합으로만 판단
|
||||
val ma5 = if (candles.size >= 5) candles.takeLast(5).map { it.close }.average() else currentClose
|
||||
val isOverheated = (currentClose / ma5) * 100 > 112.0 // 12% 이상 이격 시 과열
|
||||
|
||||
// 4. 매수 신호 확정 조건
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now // 정배열
|
||||
val rsiBull = rsiNow > RSI_THRESHOLD // 매수 강도
|
||||
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD // 수급 폭발
|
||||
val bbValid = bbPos in 0.2..0.9 // 밴드 내 안정적 위치
|
||||
|
||||
val buySignal = maBull && rsiBull && volSurge && isBreakout && !isOverheated && isDailyBullish
|
||||
|
||||
// 5. 종합 점수(0~100) 산출 가중치
|
||||
val score = (if (maBull) 25 else 0) +
|
||||
(if (rsiBull) 15 else 0) +
|
||||
(if (isBreakout) 20 else 0) +
|
||||
(minOf((volRatioNow - 1.0) * 15, 20.0)).toInt() +
|
||||
(if (bbValid) 10 else 0) +
|
||||
(if (isDailyBullish) 10 else 0)
|
||||
|
||||
// 6. 성공 확률 및 위험도 계산
|
||||
val successProb = if (buySignal) 75.0 + (score / 10.0) else 30.0 + (score / 2.0)
|
||||
|
||||
return ScalpingSignalModel(
|
||||
currentPrice = currentClose,
|
||||
buySignal = buySignal,
|
||||
compositeScore = score.coerceIn(0, 100),
|
||||
successProbPct = successProb.coerceAtMost(98.0),
|
||||
riskLevel = if (volRatioNow > 5.0) "High" else "Medium",
|
||||
rsi = rsiNow,
|
||||
volRatio = volRatioNow,
|
||||
suggestedSlPrice = currentClose * (1 + DEFAULT_SL_PCT / 100),
|
||||
suggestedTpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100),
|
||||
riskRewardRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
|
||||
)
|
||||
}
|
||||
|
||||
// --- 내부 계산 유틸리티 ---
|
||||
private fun simpleMovingAverage(values: List<Double>, window: Int): List<Double> {
|
||||
return values.windowed(window).map { it.average() }
|
||||
}
|
||||
|
||||
private fun computeRSI(closes: List<Double>, window: Int = RSI_WINDOW): List<Double> {
|
||||
val rsi = mutableListOf<Double>()
|
||||
if (closes.size < window + 1) return rsi
|
||||
val changes = closes.zipWithNext { a, b -> b - a }
|
||||
for (i in window..changes.size) {
|
||||
val windowChanges = changes.subList(i - window, i)
|
||||
val gains = windowChanges.filter { it > 0 }.sum()
|
||||
val losses = windowChanges.filter { it < 0 }.map { abs(it) }.sum()
|
||||
val rs = if (losses > 0) gains / losses else Double.POSITIVE_INFINITY
|
||||
rsi.add(100.0 - (100.0 / (1.0 + rs)))
|
||||
}
|
||||
return rsi
|
||||
}
|
||||
|
||||
private fun bollingerBands(closes: List<Double>, window: Int = SMA_LONG): Triple<List<Double>, List<Double>, List<Double>> {
|
||||
val upper = mutableListOf<Double>()
|
||||
val sma = mutableListOf<Double>()
|
||||
val lower = mutableListOf<Double>()
|
||||
for (i in window..closes.size) {
|
||||
val slice = closes.subList(i - window, i)
|
||||
val mean = slice.average()
|
||||
val std = sqrt(slice.map { (it - mean).pow(2) }.average())
|
||||
sma.add(mean)
|
||||
upper.add(mean + (std * 2))
|
||||
lower.add(mean - (std * 2))
|
||||
}
|
||||
return Triple(upper, sma, lower)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun CandleData.toScalpingCandle(): Candle {
|
||||
// 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합
|
||||
val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}"
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
|
||||
|
||||
// 2. 타임스탬프(Epoch Milliseconds) 계산
|
||||
val timestamp = try {
|
||||
val ldt = LocalDateTime.parse(dateTimeStr, formatter)
|
||||
ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
} catch (e: Exception) {
|
||||
// 시간 파싱 실패 시 현재 시스템 시간 사용
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// 3. String 필드들을 Double로 변환하여 Candle 객체 생성
|
||||
return Candle(
|
||||
timestamp = timestamp,
|
||||
open = this.stck_oprc.toDoubleOrNull() ?: 0.0,
|
||||
high = this.stck_hgpr.toDoubleOrNull() ?: 0.0,
|
||||
low = this.stck_lwpr.toDoubleOrNull() ?: 0.0,
|
||||
close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할
|
||||
volume = this.cntg_vol.toDoubleOrNull() ?: 0.0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 전체를 변환하는 유틸리티
|
||||
*/
|
||||
fun List<CandleData>.toScalpingList(): List<Candle> {
|
||||
return this.map { it.toScalpingCandle() }
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user