This commit is contained in:
lunaticbum 2026-06-26 10:17:03 +09:00
parent 81ce9b1539
commit 7474558136
2 changed files with 87 additions and 12 deletions

View File

@ -480,7 +480,7 @@ $standardizedScores
} }
val reboundRate = if (bottomPrice > 0) ((currentClose - bottomPrice) / bottomPrice * 100) else 0.0 val reboundRate = if (bottomPrice > 0) ((currentClose - bottomPrice) / bottomPrice * 100) else 0.0
if (reboundRate >= 3.0) { // 3% 이상 반등 시 사이클 종료 및 기록 if (reboundRate >= minDropToDetect) { // 3% 이상 반등 시 사이클 종료 및 기록
reboundTerms.add(i - bottomIndex) reboundTerms.add(i - bottomIndex)
dropRates.add(abs((bottomPrice - peakPrice) / peakPrice * 100)) dropRates.add(abs((bottomPrice - peakPrice) / peakPrice * 100))
reboundAmplitudes.add(reboundRate) // 🌟 상승폭 기록 reboundAmplitudes.add(reboundRate) // 🌟 상승폭 기록
@ -614,7 +614,67 @@ $standardizedScores
return VolatilityForecast(realisticHigh, realisticLow, extremeHigh, extremeLow) return VolatilityForecast(realisticHigh, realisticLow, extremeHigh, extremeLow)
} }
/**
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(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
)
}
} }
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% 이내)에 진입했는지 여부
)
data class VolatilityForecast( data class VolatilityForecast(
val realisticHigh: Double, // 1표준편차 상단 (현실적 목표가, 68% 확률 내) val realisticHigh: Double, // 1표준편차 상단 (현실적 목표가, 68% 확률 내)
val realisticLow: Double, // 1표준편차 하단 (현실적 지지선) val realisticLow: Double, // 1표준편차 하단 (현실적 지지선)

View File

@ -462,7 +462,7 @@ object AutoTradingManager {
} else { } else {
val now = LocalTime.now() val now = LocalTime.now()
val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(18, 0))) { val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(16, 0))) {
// 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도) // 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도)
KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX) KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX)
} else { } else {
@ -973,10 +973,10 @@ object AutoTradingManager {
if (corpInfo?.cName.isNullOrEmpty()) { if (corpInfo?.cName.isNullOrEmpty()) {
false false
} else if (it.code !in myHoldings && } else if (it.code !in myHoldings &&
it.code !in pendingStocks && it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } && it.code !in executionCache.values.map { it.code } &&
it.code !in failList && it.code !in failList &&
it.code !in isSafetyBeltStockCodes){ it.code !in isSafetyBeltStockCodes){
isOk isOk
} else { } else {
false false
@ -1060,7 +1060,7 @@ object AutoTradingManager {
if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) { if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) {
cancelAllPendingSellOrders() cancelAllPendingSellOrders()
isExecuted = true isExecuted = true
} else if ( (now.isBefore(LocalTime.of(16,0)) && now.isAfter(KisSession.endBuyTime())) ) { } else if ( (now.isBefore(LocalTime.of(15,40)) && now.isAfter(KisSession.endBuyTime())) ) {
val unfilledResult = KisTradeService.fetchUnfilledOrders() val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response -> unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
@ -1082,8 +1082,11 @@ object AutoTradingManager {
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
checkBalance() checkBalance()
isExecuted = true isExecuted = true
} else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || } else if (
(now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 0)) { (
(now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) ||
(now.isAfter(LocalTime.of(15,40)) && now.isBefore(LocalTime.of(20,0)) && KisSession.tradeConfig.after_nxt)
) && (currentMinute % 2 == 0)) {
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
" - ", " - ",
" - ", " - ",
@ -1158,17 +1161,18 @@ object AutoTradingManager {
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) // 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?)
val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 40)
val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0
// 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?) // 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?)
val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData) val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0)
val isApproaching = tempAnalyzer.checkReboundApproaching( val isApproaching = tempAnalyzer.checkReboundApproaching(
candles = dailyData, candles = dailyData,
avgReboundTerm = dailyStats.avgReboundPeriod, avgReboundTerm = dailyStats.avgReboundPeriod,
dropThreshold = dailyStats.avgDropRate * 0.8, dropThreshold = dailyStats.avgDropRate,
timeTolerance = dailyStats.timeTolerance timeTolerance = dailyStats.timeTolerance
) )
print("-> [${stock.name}] 필터링 ${dailyStats.avgReboundPeriod} ${dailyStats.avgDropRate} ${dailyStats.timeTolerance}")
val isSteadyUptrend = tempAnalyzer.checkSteadyUptrend(dailyData) val isSteadyUptrend = tempAnalyzer.checkSteadyUptrend(dailyData)
@ -1183,7 +1187,18 @@ object AutoTradingManager {
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)
if (dropPrediction != null) {
// 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지!
// 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%)
if (dropPrediction.remainingDropRate < -2.0 && !dropPrediction.isBottomZone) {
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)}% 추가 하락 위험) | ")
return@withTimeout // 매수 후보에서 과감히 제외!
}
// 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김
}
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)") println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)")
if (!isSafetyBeltStockCodes.contains(stock.code)) { if (!isSafetyBeltStockCodes.contains(stock.code)) {
val analyzer = coroutineScope { val analyzer = coroutineScope {