2026-04-07 17:32:21 +09:00
|
|
|
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, // 중기 (주봉/재무)
|
2026-04-08 14:18:09 +09:00
|
|
|
val longTerm: Int // 장기 (월봉/펀더멘털)
|
2026-04-07 17:32:21 +09:00
|
|
|
) {
|
|
|
|
|
override fun toString(): String {
|
|
|
|
|
return """
|
|
|
|
|
평점 : ${avg()}
|
|
|
|
|
초단 : $ultraShort
|
|
|
|
|
단기 : $shortTerm
|
|
|
|
|
중기 : $midTerm
|
|
|
|
|
장기 : $longTerm
|
|
|
|
|
""".trimIndent()
|
|
|
|
|
}
|
|
|
|
|
fun avg() = listOf(ultraShort, shortTerm, midTerm, longTerm).average()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Serializable
|
|
|
|
|
class TechnicalAnalyzer {
|
2026-04-08 14:18:09 +09:00
|
|
|
// 주의: min30은 '30분봉'이 아니라 '1분 단위 캔들 30개'를 의미합니다.
|
2026-04-07 17:32:21 +09:00
|
|
|
var min30: List<CandleData> = emptyList()
|
2026-04-08 14:18:09 +09:00
|
|
|
var daily: List<CandleData> = emptyList()
|
|
|
|
|
var weekly: List<CandleData> = emptyList()
|
|
|
|
|
var monthly: List<CandleData> = emptyList()
|
2026-04-07 17:32:21 +09:00
|
|
|
|
|
|
|
|
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
|
|
|
|
|
|
2026-06-19 14:31:33 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* [신규] 기간별(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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 17:32:21 +09:00
|
|
|
/**
|
2026-04-08 14:18:09 +09:00
|
|
|
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
|
2026-04-07 17:32:21 +09:00
|
|
|
*/
|
|
|
|
|
fun generateComprehensiveSignal(): ScalpingSignalModel {
|
|
|
|
|
val scalpingAnalyzer = ScalpingAnalyzer()
|
|
|
|
|
val dailyBullish = isDailyBullish()
|
|
|
|
|
|
|
|
|
|
// 1. 기본 스캘핑 신호 생성
|
|
|
|
|
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
|
|
|
|
|
var refinedScore = baseSignal.compositeScore.toDouble()
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
// 2. 점수 정교화 (가점/감점 요인)
|
2026-04-07 17:32:21 +09:00
|
|
|
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
2026-06-19 14:31:33 +09:00
|
|
|
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개 모두 일치하면 '초강력 추세'로 보너스 추가 가점 (선택 사항)
|
2026-04-07 17:32:21 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [보완] 자금 유입 강도(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
|
|
|
|
|
|
2026-06-19 14:31:33 +09:00
|
|
|
// 🌟 [추가 보완 1] 기간별 최고가 저항선 감점 적용
|
|
|
|
|
val highPricePenalty = calculateHighPricePenalty()
|
|
|
|
|
refinedScore += highPricePenalty // 음수 값이 반환되므로 가산
|
|
|
|
|
|
|
|
|
|
// 🌟 [추가 보완 2] 낙폭 과대 후 단기 반등 추세 가점 적용
|
|
|
|
|
val reboundBonus = calculateReboundBonus()
|
|
|
|
|
refinedScore += reboundBonus
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
// 🚀 [마이크로 분석] 기존 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 // 초단기 투매 과도 (기술적 반등 타점 노림)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 17:32:21 +09:00
|
|
|
return baseSignal.copy(
|
|
|
|
|
compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(),
|
|
|
|
|
successProbPct = (refinedScore * 0.85).coerceAtMost(98.0)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:31:33 +09:00
|
|
|
/**
|
|
|
|
|
* [신규] 종목의 평균 반등 텀(캔들 수)을 계산합니다.
|
|
|
|
|
* * @param candles 분석할 캔들 리스트 (daily, weekly 등)
|
|
|
|
|
* @param dropThreshold 고점 대비 이 비율(%)만큼 떨어지면 하락으로 간주 (기본 5.0%)
|
|
|
|
|
* @param reboundThreshold 바닥 대비 이 비율(%)만큼 오르면 반등으로 간주 (기본 3.0%)
|
|
|
|
|
* @return 평균 반등에 소요된 캔들 수 (사이클이 없으면 0.0 반환)
|
|
|
|
|
*/
|
|
|
|
|
fun calculateAverageReboundTerm(
|
|
|
|
|
candles: List<CandleData>,
|
|
|
|
|
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<Int>()
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
/**
|
|
|
|
|
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직
|
|
|
|
|
*/
|
2026-04-07 17:32:21 +09:00
|
|
|
fun isOverheatedStock(): Boolean {
|
2026-04-08 14:18:09 +09:00
|
|
|
if (daily.size < 20) return false
|
|
|
|
|
val currentPrice = daily.last().stck_prpr.toDouble()
|
|
|
|
|
|
|
|
|
|
// 1. 일봉 20일선 이격도
|
2026-04-07 17:32:21 +09:00
|
|
|
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
|
|
|
|
val disparityDaily = (currentPrice / ma20Daily) * 100
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
// 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분 내 순간 펌핑(꼭지)
|
2026-04-07 17:32:21 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
// --- 이하 유틸리티 함수군 (변경 없음) ---
|
2026-04-07 17:32:21 +09:00
|
|
|
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()
|
|
|
|
|
}
|
2026-06-19 14:31:33 +09:00
|
|
|
/**
|
|
|
|
|
* [신규] 현재 주가가 통계적 반등 주기에 근접했는지 확인합니다.
|
|
|
|
|
* @param candles 분석할 캔들 리스트 (daily, weekly 등)
|
|
|
|
|
* @param avgReboundTerm 앞서 계산한 평균 반등 소요 캔들 수
|
|
|
|
|
* @param dropThreshold 하락장으로 판단할 기준 하락률 (기본 5.0%)
|
|
|
|
|
* @param timeTolerance 오차 허용 범위 (기본 1.5 -> 평균 주기보다 하루이틀 빠르거나 늦어도 인정)
|
|
|
|
|
*/
|
|
|
|
|
fun checkReboundApproaching(
|
|
|
|
|
candles: List<CandleData>,
|
|
|
|
|
avgReboundTerm: Double,
|
|
|
|
|
dropThreshold: Double = 5.0,
|
|
|
|
|
timeTolerance: Double = 1.5
|
|
|
|
|
): 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
|
|
|
|
|
// 조건 A: 가격이 통계적 하락폭만큼 충분히 빠졌는가?
|
|
|
|
|
|
|
|
|
|
val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance)
|
|
|
|
|
// 조건 B: 반등 '최소' 기간을 채웠는가? (떨어지는 칼날을 너무 일찍 잡는 것 방지)
|
|
|
|
|
|
|
|
|
|
val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2))
|
|
|
|
|
// 조건 C: 반등 '최대' 기간을 넘기지 않았는가? (죽은 주식처럼 너무 오래 횡보하는 것 방지)
|
|
|
|
|
|
|
|
|
|
// 🌟 3. 3개 중 2개 이상 만족 시 반등 임박(Approaching)으로 판단
|
|
|
|
|
val passedConditions = listOf(isPriceDropped, isPastMinTime, isWithinMaxTime).count { it }
|
|
|
|
|
|
|
|
|
|
return passedConditions >= 2
|
|
|
|
|
}
|
2026-04-07 17:32:21 +09:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
// [표준화된 분석 점수]
|
2026-04-07 17:32:21 +09:00
|
|
|
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()
|
|
|
|
|
|
2026-04-08 14:18:09 +09:00
|
|
|
// [시계열 가격 흐름]
|
2026-04-07 17:32:21 +09:00
|
|
|
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()
|
|
|
|
|
}
|
2026-06-19 14:31:33 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 종목의 과거 차트를 분석하여 고유의 반등 통계(평균 주기, 오차 범위, 평균 하락폭)를 도출합니다.
|
|
|
|
|
*/
|
|
|
|
|
fun calculateDynamicReboundStats(
|
|
|
|
|
candles: List<CandleData>,
|
|
|
|
|
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<Int>()
|
|
|
|
|
val dropRates = mutableListOf<Double>()
|
|
|
|
|
val reboundAmplitudes = mutableListOf<Double>() // 🌟 [신규] 반등 상승폭 수집
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-26 10:17:03 +09:00
|
|
|
if (reboundRate >= minDropToDetect) { // 3% 이상 반등 시 사이클 종료 및 기록
|
2026-06-19 14:31:33 +09:00
|
|
|
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<CandleData>): 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<CandleData>, 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<Double>()
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
2026-06-26 10:17:03 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
|
|
|
|
*/
|
|
|
|
|
fun predictDropBottom(
|
|
|
|
|
candles: List<CandleData>,
|
|
|
|
|
reboundStats: ReboundStats,
|
|
|
|
|
volatility: VolatilityForecast
|
|
|
|
|
): DropPrediction? {
|
|
|
|
|
// 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가
|
|
|
|
|
if (candles.size < 20 || !reboundStats.isValid) return null
|
|
|
|
|
|
|
|
|
|
// 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점)
|
|
|
|
|
val recentCandles = candles.takeLast(20)
|
|
|
|
|
var recentPeakPrice = 0.0
|
|
|
|
|
|
|
|
|
|
for (i in recentCandles.indices.reversed()) {
|
|
|
|
|
val highPrice = recentCandles[i].stck_hgpr.toDouble()
|
|
|
|
|
if (highPrice > recentPeakPrice) {
|
|
|
|
|
recentPeakPrice = highPrice
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (recentPeakPrice == 0.0) return null
|
|
|
|
|
|
|
|
|
|
val currentPrice = candles.last().stck_prpr.toDouble()
|
|
|
|
|
|
|
|
|
|
// 2. 현재까지의 하락률 계산
|
|
|
|
|
val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락)
|
|
|
|
|
|
|
|
|
|
// 3. 1차 예상 바닥가 (과거 평균 하락폭 적용)
|
|
|
|
|
// 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원
|
|
|
|
|
val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0))
|
|
|
|
|
|
|
|
|
|
// 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?)
|
|
|
|
|
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
|
|
|
|
|
|
|
|
|
|
// 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때)
|
|
|
|
|
val isBottomZone = currentPrice <= (expectedBottomPrice * 1.02) || currentPrice <= (volatility.extremeLow * 1.02)
|
|
|
|
|
|
|
|
|
|
return DropPrediction(
|
|
|
|
|
recentPeakPrice = recentPeakPrice,
|
|
|
|
|
expectedBottomPrice = expectedBottomPrice,
|
|
|
|
|
extremeSupportPrice = volatility.extremeLow,
|
|
|
|
|
currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%)
|
|
|
|
|
remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻
|
|
|
|
|
isBottomZone = isBottomZone
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:31:33 +09:00
|
|
|
}
|
2026-06-26 10:17:03 +09:00
|
|
|
|
|
|
|
|
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% 이내)에 진입했는지 여부
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-19 14:31:33 +09:00
|
|
|
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 = 1.5, // 오차 허용 범위 (표준편차)
|
|
|
|
|
val avgDropRate: Double = 5.0, // 평균 하락폭
|
|
|
|
|
val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가?
|
|
|
|
|
val isValid: Boolean = false
|
|
|
|
|
)
|