...
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package analyzer
|
||||
|
||||
import model.CandleData
|
||||
import service.InvestmentGrade
|
||||
|
||||
object AdvancedTradeAssistant {
|
||||
|
||||
// 1. VWAP (거래량 가중 평균 단가) 계산기
|
||||
// 주로 최근 30분(min30) 데이터를 받아 초단기 세력 평단가를 구합니다.
|
||||
fun calculateMicroVWAP(candles: List<CandleData>): Double {
|
||||
if (candles.isEmpty()) return 0.0
|
||||
var typicalVolumeSum = 0.0
|
||||
var totalVolume = 0.0
|
||||
for (candle in candles) {
|
||||
val typicalPrice = (candle.stck_hgpr.toDouble() + candle.stck_lwpr.toDouble() + candle.stck_prpr.toDouble()) / 3
|
||||
val volume = candle.cntg_vol.toDouble()
|
||||
typicalVolumeSum += typicalPrice * volume
|
||||
totalVolume += volume
|
||||
}
|
||||
return if (totalVolume == 0.0) 0.0 else typicalVolumeSum / totalVolume
|
||||
}
|
||||
|
||||
// 2. 볼린저 밴드 하단선 계산기 (20일선 기준)
|
||||
fun calculateBollingerLowerBand(candles: List<CandleData>, period: Int = 20): Double {
|
||||
if (candles.size < period) return 0.0
|
||||
val targetCandles = candles.takeLast(period).map { it.stck_prpr.toDouble() }
|
||||
val ma20 = targetCandles.average()
|
||||
|
||||
// 표준편차 계산
|
||||
val variance = targetCandles.map { Math.pow(it - ma20, 2.0) }.average()
|
||||
val stdDev = Math.sqrt(variance)
|
||||
|
||||
// 하단선 = 20일 이동평균선 - (2 * 표준편차)
|
||||
return ma20 - (2 * stdDev)
|
||||
}
|
||||
|
||||
// 3. 🎯 특정 그레이드에 맞춤형 '매수 조언(Confirmation)' 제공
|
||||
fun confirmTrade(
|
||||
currentGrade: InvestmentGrade,
|
||||
currentPrice: Double,
|
||||
min30: List<CandleData>,
|
||||
daily: List<CandleData>
|
||||
): TradeAdvice {
|
||||
return when (currentGrade) {
|
||||
// [초단타 등급] VWAP 필터 적용
|
||||
InvestmentGrade.LEVEL_1_SPECULATIVE, InvestmentGrade.LEVEL_2_HIGH_RISK -> {
|
||||
val vwap = calculateMicroVWAP(min30)
|
||||
if (currentPrice >= vwap) {
|
||||
// 현재가가 세력 평단가(VWAP) 위에서 놀고 있음 -> 매수 확정 및 가산점
|
||||
TradeAdvice(isConfirmed = true, confidenceBonus = +5.0, reason = "VWAP 돌파(강한 수급 방어)")
|
||||
} else {
|
||||
// 투매 구간 (세력 평단가 이탈) -> 진입 포기 (LEVEL_0으로 강등 권고)
|
||||
TradeAdvice(isConfirmed = false, confidenceBonus = -20.0, reason = "VWAP 하향 이탈(투매 위험)")
|
||||
}
|
||||
}
|
||||
|
||||
// [우량주 눌림목 등급] 볼린저 밴드 필터 적용
|
||||
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND, InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> {
|
||||
val lowerBand = calculateBollingerLowerBand(daily)
|
||||
// 💡 [수정] 1.05배, 1.15배 디테일 추가
|
||||
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05) {
|
||||
TradeAdvice(isConfirmed = true, confidenceBonus = +8.0, reason = "볼린저 밴드 하단 터치(통계적 바닥 확인)")
|
||||
} else if (lowerBand > 0 && currentPrice > lowerBand * 1.15) {
|
||||
TradeAdvice(isConfirmed = true, confidenceBonus = -5.0, reason = "볼린저 밴드 하단 미도달(추가 하락 가능성)")
|
||||
} else {
|
||||
TradeAdvice(isConfirmed = true, confidenceBonus = 0.0, reason = "정상 추세 구간")
|
||||
}
|
||||
}
|
||||
|
||||
else -> TradeAdvice(isConfirmed = true, confidenceBonus = 0.0, reason = "추가 검증 없음")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 조언 결과를 담는 데이터 클래스
|
||||
data class TradeAdvice(
|
||||
val isConfirmed: Boolean,
|
||||
val confidenceBonus: Double,
|
||||
val reason: String
|
||||
)
|
||||
@@ -62,6 +62,17 @@ object FinancialAnalyzer {
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
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 calculateScore(fs: FinancialStatement): Int {
|
||||
var score = 50.0 // 중립 시작
|
||||
|
||||
@@ -101,106 +112,3 @@ object FinancialAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ data class InvestmentScores(
|
||||
val ultraShort: Int, // 초단기 (분봉/에너지)
|
||||
val shortTerm: Int, // 단기 (일봉/뉴스)
|
||||
val midTerm: Int, // 중기 (주봉/재무)
|
||||
val longTerm: Int // 장기 (월봉/펀더멘털)
|
||||
val longTerm: Int // 장기 (월봉/펀더멘털)
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return """
|
||||
@@ -28,15 +28,16 @@ data class InvestmentScores(
|
||||
|
||||
@Serializable
|
||||
class TechnicalAnalyzer {
|
||||
var monthly: List<CandleData> = emptyList()
|
||||
var weekly: List<CandleData> = emptyList()
|
||||
var daily: List<CandleData> = emptyList()
|
||||
// 주의: min30은 '30분봉'이 아니라 '1분 단위 캔들 30개'를 의미합니다.
|
||||
var min30: List<CandleData> = emptyList()
|
||||
var daily: List<CandleData> = emptyList()
|
||||
var weekly: List<CandleData> = emptyList()
|
||||
var monthly: List<CandleData> = emptyList()
|
||||
|
||||
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* [신규] 기술적 지표와 추세를 결합한 종합 신호 생성
|
||||
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
|
||||
*/
|
||||
fun generateComprehensiveSignal(): ScalpingSignalModel {
|
||||
val scalpingAnalyzer = ScalpingAnalyzer()
|
||||
@@ -44,10 +45,9 @@ class TechnicalAnalyzer {
|
||||
|
||||
// 1. 기본 스캘핑 신호 생성
|
||||
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
|
||||
|
||||
// 2. 점수 정교화 (가점/감점 요인)
|
||||
var refinedScore = baseSignal.compositeScore.toDouble()
|
||||
|
||||
// 2. 점수 정교화 (가점/감점 요인)
|
||||
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
||||
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
|
||||
refinedScore += 10.0
|
||||
@@ -67,20 +67,64 @@ class TechnicalAnalyzer {
|
||||
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 (min30.size < 20 || daily.size < 20) return false
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
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
|
||||
|
||||
// 이격도 115% 이상이면 주의, 125% 이상이면 과열
|
||||
return disparityDaily > 115.0
|
||||
// 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 {
|
||||
@@ -100,7 +144,7 @@ class TechnicalAnalyzer {
|
||||
)
|
||||
}
|
||||
|
||||
// --- 유틸리티 함수군 (기존 로직 유지 및 보완) ---
|
||||
// --- 이하 유틸리티 함수군 (변경 없음) ---
|
||||
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
|
||||
if (candles.size < period + 1) return 0.0
|
||||
val sub = candles.takeLast(period + 1)
|
||||
@@ -170,14 +214,14 @@ class TechnicalAnalyzer {
|
||||
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}]"
|
||||
}
|
||||
@@ -188,15 +232,13 @@ class TechnicalAnalyzer {
|
||||
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