../.
This commit is contained in:
@@ -618,52 +618,112 @@ $standardizedScores
|
|||||||
/**
|
/**
|
||||||
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
||||||
|
* ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다.
|
||||||
|
*/
|
||||||
fun predictDropBottom(
|
fun predictDropBottom(
|
||||||
candles: List<CandleData>,
|
candles: List<CandleData>,
|
||||||
reboundStats: ReboundStats,
|
reboundStats: ReboundStats,
|
||||||
volatility: VolatilityForecast
|
volatility: VolatilityForecast,
|
||||||
|
currentAtr: Double
|
||||||
): DropPrediction? {
|
): DropPrediction? {
|
||||||
// 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가
|
|
||||||
if (candles.size < 20 || !reboundStats.isValid) return null
|
if (candles.size < 20 || !reboundStats.isValid) return null
|
||||||
|
|
||||||
// 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점)
|
// 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외)
|
||||||
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
|
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
|
||||||
var recentPeakPrice = 0.0
|
|
||||||
|
|
||||||
for (i in recentCandles.indices.reversed()) {
|
// 1. 고가 평균점 (Smoothed Peak) 만들기
|
||||||
val highPrice = recentCandles[i].stck_hgpr.toDouble()
|
// 최고가들을 내림차순 정렬하여 상위 3개의 평균을 구함 (비정상적인 윗꼬리 1~2개 무시 효과)
|
||||||
if (highPrice > recentPeakPrice) {
|
val topHighs = recentCandles.map { it.stck_hgpr.toDouble() }.sortedDescending()
|
||||||
recentPeakPrice = highPrice
|
val smoothedPeak = if (topHighs.size >= 3) {
|
||||||
}
|
topHighs.take(3).average()
|
||||||
|
} else {
|
||||||
|
topHighs.firstOrNull() ?: 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recentPeakPrice == 0.0) return null
|
if (smoothedPeak == 0.0) return null
|
||||||
|
|
||||||
|
// 2. 저점 평균점 (Smoothed Bottom) 만들기
|
||||||
|
// 최저가들을 오름차순 정렬하여 하위 3개의 평균을 구함 (순간적인 투매 아랫꼬리 방어)
|
||||||
|
val bottomLows = recentCandles.map { it.stck_lwpr.toDouble() }.sorted()
|
||||||
|
val smoothedBottom = if (bottomLows.size >= 3) {
|
||||||
|
bottomLows.take(3).average()
|
||||||
|
} else {
|
||||||
|
bottomLows.firstOrNull() ?: 0.0
|
||||||
|
}
|
||||||
|
|
||||||
val currentPrice = candles.last().stck_prpr.toDouble()
|
val currentPrice = candles.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
// 2. 현재까지의 하락률 계산
|
// 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정
|
||||||
val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락)
|
// 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦
|
||||||
|
val truePeak = smoothedPeak - (currentAtr * 0.3)
|
||||||
|
|
||||||
// 3. 1차 예상 바닥가 (과거 평균 하락폭 적용)
|
// 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산
|
||||||
// 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원
|
val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0
|
||||||
val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0))
|
|
||||||
|
|
||||||
// 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?)
|
// 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출
|
||||||
|
val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0))
|
||||||
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
|
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
|
||||||
|
|
||||||
// 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때)
|
// 바닥권 인정 마진 (ATR 기반)
|
||||||
val isBottomZone = currentPrice <= (expectedBottomPrice * 1.015) || currentPrice <= (volatility.extremeLow * 1.015)
|
val bottomMargin = currentAtr * 0.7
|
||||||
|
|
||||||
|
// 저점 평균(smoothedBottom)도 마지노선 계산에 추가 가중치로 섞어줌으로써 저점에 더 밀착시킴
|
||||||
|
val adjustedExtremeLow = (volatility.extremeLow + smoothedBottom) / 2.0
|
||||||
|
|
||||||
|
val isBottomZone = currentPrice <= (expectedBottomPrice + bottomMargin) || currentPrice <= (adjustedExtremeLow + bottomMargin)
|
||||||
|
|
||||||
return DropPrediction(
|
return DropPrediction(
|
||||||
recentPeakPrice = recentPeakPrice,
|
recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달
|
||||||
expectedBottomPrice = expectedBottomPrice,
|
expectedBottomPrice = expectedBottomPrice,
|
||||||
extremeSupportPrice = volatility.extremeLow,
|
extremeSupportPrice = adjustedExtremeLow, // 보정된 지지선 전달
|
||||||
currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%)
|
currentDropRate = -currentDropRate,
|
||||||
remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻
|
remainingDropRate = -remainingDropRate,
|
||||||
isBottomZone = isBottomZone
|
isBottomZone = isBottomZone
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🌟 [신규] 바닥권 도달 시, 하락이 멈추고 지지선이 형성되었는지(Brake) 확인합니다.
|
||||||
|
*/
|
||||||
|
fun checkBrakeAndReversal(candles: List<CandleData>): Boolean {
|
||||||
|
if (candles.size < 3) return false
|
||||||
|
|
||||||
|
val today = candles.last()
|
||||||
|
val yesterday = candles[candles.size - 2]
|
||||||
|
|
||||||
|
val tClose = today.stck_prpr.toDouble()
|
||||||
|
val tOpen = today.stck_oprc.toDouble()
|
||||||
|
val tHigh = today.stck_hgpr.toDouble()
|
||||||
|
val tLow = today.stck_lwpr.toDouble()
|
||||||
|
val tVol = today.cntg_vol.toDouble()
|
||||||
|
|
||||||
|
val yVol = yesterday.cntg_vol.toDouble()
|
||||||
|
|
||||||
|
// 1. 밑꼬리 확인 (망치형 / 도지형)
|
||||||
|
// 몸통(Body) 대비 아래쪽 꼬리(Lower Shadow)가 얼마나 긴가?
|
||||||
|
val body = abs(tClose - tOpen)
|
||||||
|
val lowerShadow = minOf(tClose, tOpen) - tLow
|
||||||
|
val upperShadow = tHigh - maxOf(tClose, tOpen)
|
||||||
|
|
||||||
|
// 꼬리가 몸통보다 1.5배 이상 길고, 윗꼬리보다 아랫꼬리가 더 길면 강력한 누군가의 '매수 개입(지지)'으로 봅니다.
|
||||||
|
val hasLongLowerShadow = (lowerShadow > body * 1.5) && (lowerShadow > upperShadow)
|
||||||
|
|
||||||
|
// 2. 단기 양봉 전환 (하락을 멈추고 고개를 듦)
|
||||||
|
val isBullishBrake = tClose > tOpen && tClose >= yesterday.stck_prpr.toDouble()
|
||||||
|
|
||||||
|
// 3. 투매 진정 (거래량 급감)
|
||||||
|
// 전일 대비 거래량이 눈에 띄게 줄었다는 것은 매도세(던지는 물량)가 말랐다는 뜻입니다.
|
||||||
|
val isVolumeDriedUp = tVol < yVol * 0.7
|
||||||
|
|
||||||
|
// 🌟 지지(밑꼬리)가 나왔거나, 양봉으로 돌렸거나, 던지는 물량이 마른 상태 중 하나라도 충족해야 브레이크가 걸린 것으로 봅니다.
|
||||||
|
return hasLongLowerShadow || isBullishBrake || isVolumeDriedUp
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DropPrediction(
|
data class DropPrediction(
|
||||||
|
|||||||
@@ -1186,22 +1186,28 @@ object AutoTradingManager {
|
|||||||
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
|
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
|
||||||
val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend
|
val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend
|
||||||
|
|
||||||
|
val currentAtr = tempAnalyzer.calculateATR(dailyData)
|
||||||
if (!isProfitable || !isValidEntryTiming) {
|
if (!isProfitable || !isValidEntryTiming) {
|
||||||
print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ")
|
print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ")
|
||||||
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
|
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
|
||||||
}
|
}
|
||||||
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility)
|
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr)
|
||||||
|
|
||||||
if (dropPrediction != null) {
|
if (dropPrediction != null) {
|
||||||
// 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지!
|
// 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지
|
||||||
// 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%)
|
if (dropPrediction.remainingDropRate < -2.0 && !dropPrediction.isBottomZone) {
|
||||||
if (dropPrediction.remainingDropRate < -1.5 && !dropPrediction.isBottomZone) {
|
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 추가 하락 위험) | ")
|
||||||
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)}% 추가 하락 위험) | ")
|
return@withTimeout
|
||||||
return@withTimeout // 매수 후보에서 과감히 제외!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김
|
// 💡 [방어 로직 2 - 신규] 가격은 바닥권에 왔지만, 캔들에 브레이크(지지)가 걸렸는가?
|
||||||
|
if (dropPrediction.isBottomZone) {
|
||||||
|
val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData)
|
||||||
|
if (!hasBrake) {
|
||||||
|
print("-> [${stock.name}] 바닥 가격 도달했으나, 브레이크(지지/거래량 진정) 미확인. 떨어지는 칼날 회피 | ")
|
||||||
|
return@withTimeout // 브레이크가 없으면 매수 안 함!
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (KisSession.tradeConfig.isUpcomingDividend) {
|
if (KisSession.tradeConfig.isUpcomingDividend) {
|
||||||
|
|||||||
Reference in New Issue
Block a user