This commit is contained in:
2026-09-15 11:37:09 +09:00
parent 3998cf159a
commit 442cdf0877
2 changed files with 120 additions and 119 deletions
@@ -354,7 +354,7 @@ class TechnicalAnalyzer {
// 🌟 3. 3개 중 2개 이상 만족 시 반등 임박(Approaching)으로 판단 // 🌟 3. 3개 중 2개 이상 만족 시 반등 임박(Approaching)으로 판단
val passedConditions = listOf(isPriceDropped, isPastMinTime, isWithinMaxTime).count { it } val passedConditions = listOf(isPriceDropped, isPastMinTime, isWithinMaxTime).count { it }
//A. 진입 하락률 기준checkReboundApproaching 3개 중 2개 만족 시 통과 가격 하락 조건(isPriceDropped)을 필수 조건(AND)으로 고정 //A. 진입 하락률 기준checkReboundApproaching 3개 중 2개 만족 시 통과 가격 하락 조건(isPriceDropped)을 필수 조건(AND)으로 고정
return passedConditions >= 2 return passedConditions > 2
} }
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double { fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
+119 -118
View File
@@ -590,7 +590,7 @@ object AutoTradingManager {
qty = holding.availOrderCount, qty = holding.availOrderCount,
price = targetPrice.toInt().toString(), price = targetPrice.toInt().toString(),
isBuy = false, isBuy = false,
orderDivision = if (marketCode.equals("Y")) "07" else "", orderDivision = if (marketCode.equals("Y")) "41" else "",
marketCode = if (marketCode.equals("Y")) "KRX" else "NXT" marketCode = if (marketCode.equals("Y")) "KRX" else "NXT"
).onSuccess { newOrderNo -> ).onSuccess { newOrderNo ->
println("✅ [${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주문 완료] ${holding.name}: $newOrderNo") println("✅ [${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주문 완료] ${holding.name}: $newOrderNo")
@@ -743,7 +743,7 @@ object AutoTradingManager {
) { ) {
var targetPrice = holding.avgPrice.toDouble() var targetPrice = holding.avgPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize( targetPrice = MarketUtil.roundToTickSize(
targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend targetPrice + (MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend)
) )
tradeService.postOrder( tradeService.postOrder(
stockCode = holding.code, stockCode = holding.code,
@@ -761,7 +761,7 @@ object AutoTradingManager {
TradingLogStore.addNotice( TradingLogStore.addNotice(
"보유주식[${holding.name}]", "보유주식[${holding.name}]",
holding.code, holding.code,
"매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문 ${if (isSuccess) "성공" else "실패[${errMsg}]"}" "매수가 기준 (${holding.avgPrice.toDouble()} ${KisSession.tradeConfig.autoSellOrderAppend}호가 위[${targetPrice}] 매도 주문 ${if (isSuccess) "성공" else "실패[${errMsg}]"}"
) )
} else if (KisSession.config.stop_Loss } else if (KisSession.config.stop_Loss
&& holding != null && holding.quantity.toInt() > 0 && holding != null && holding.quantity.toInt() > 0
@@ -1323,7 +1323,7 @@ object AutoTradingManager {
true true
) )
var list = mutableListOf<String>("X") var list = mutableListOf<String>("X")
if (now.hour != 8 && now.hour < 18) { if (now.hour != 8 && now.hour < 20) {
list.add("Y") list.add("Y")
} }
list.forEach { code -> list.forEach { code ->
@@ -1363,69 +1363,98 @@ object AutoTradingManager {
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX)
val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX)
// 개별 종목 분석은 최대 2분으로 제한 // 개별 종목 분석은 최대 2분으로 제한
withTimeout(ONE_STOCK_ALYSIS_TIME) { withTimeout(ONE_STOCK_ALYSIS_TIME) {
// -------------------------------------------------------------
// [1차 필터: 가벼운 메타데이터 & 정책 검증 (불필요한 API 호출 방지)]
// -------------------------------------------------------------
// 1. 종목명 및 기업 고유번호 유효성 검사
val corpInfo = DartCodeManager.getCorpCode(stock.code) val corpInfo = DartCodeManager.getCorpCode(stock.code)
if (corpInfo?.cName.isNullOrEmpty()) { if (corpInfo?.cName.isNullOrEmpty()) {
print("-> 기업명을 못찾아서 제외 | ") print("-> 기업명을 못찾아서 제외 | ")
return@withTimeout return@withTimeout
} }
if (currentBalance?.getHoldings()
?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2 } == true // 2. [보완] 재무 건전성 미달 종목 사전 차단 (최상단으로 이동하여 차트 API 소모 방지)
) { if (isSafetyBeltStockCodes.contains(stock.code)) {
println("물타기 대상 분석") print("-> [${stock.name}] 재무 건전성 미달(안전벨트 탈락 종목) 제외 | ")
return@withTimeout
} }
// 3. 물타기 대상 로깅
if (currentBalance?.getHoldings()?.any { it.code == stock.code && it.quantity.toInt() > 2 } == true) {
println("물타기 대상 분석: ${stock.name}")
}
// 초기 콜백 알림 (분석 진행 표시용)
callback(TradingDecision().apply { callback(TradingDecision().apply {
this.stockCode = stock.code this.stockCode = stock.code
this.confidence = -1.0 this.confidence = -1.0
this.stockName = stock.name this.stockName = stock.name
}, false) }, false)
val dailyData = // 4. [보완] 배당 전략 사용 시 사전 필터링 (복잡한 기술 분석 전 조기 탈락)
tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() if (KisSession.tradeConfig.isUpcomingDividend) {
?: return@withTimeout val dividend = KisTradeService.fetchUpcomingDividend(stock.code).getOrNull()
val today = dailyData.lastOrNull() ?: null if (dividend?.hasDividend == true) {
var rate = today?.getFluctuationRate() ?: 0.0 println("[${stock.name}] 배당락일 ${dividend.exDividendDate} : ${dividend.dividendAmount}")
val isOk = } else {
((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1))) print("-> [${stock.name}] 배당 정보 없어 분석 종료 | ")
return@withTimeout
}
}
// -------------------------------------------------------------
// [2차 필터: 일봉 데이터 수집 및 현재가/호가/리스크 검증]
// -------------------------------------------------------------
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull()
?: return@withTimeout
// 5. [보완] 데이터 부족 종목 방어 (신규 상장주, 통계 최소 표본 30봉 미만 차단)
if (dailyData.size < 30) {
print("-> [${stock.name}] 캔들 데이터 부족(30봉 미만) 제외 | ")
return@withTimeout
}
val today = dailyData.lastOrNull()
val rate = today?.getFluctuationRate() ?: 0.0
val isOk = (rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1))
delay(50) delay(50)
// 1. var 대신 val을 사용해야 아래에서 스마트 캐스트가 작동하여 !!를 안 써도 됩니다.
val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull() val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull()
if (today == null || currentStock == null) { if (today == null || currentStock == null) {
// failList.add(stock.code) print("-> 금일 금액 조회 실패 | isOk: ${isOk} | ")
print("-> 금일 금액 조회 실패 | ${isOk}")
return@withTimeout return@withTimeout
} }
// 3. 위에서 확실하게 null 체크를 했으므로, 이제 currentStock은 절대 null이 아닙니다.
// 안전하게(Safe call ? 없이) 바로 접근 가능합니다.
val currentPrice = currentStock.stck_prpr.toDouble()
println("${stock.name}[${stock.code}] 현재가 : ${currentPrice} , 변동률 : ${rate} , 거래 기준 : ${isOk}")
// 4. 위험한 !! 단언 기호 없이 깔끔하게 호출 val currentPrice = currentStock.stck_prpr.toDouble()
println("${stock.name}[${stock.code}] 현재가: ${currentPrice}, 변동률: ${rate}%, 거래기준(isOk): ${isOk}")
// 6. 리스크 매니저 검문소 통과 여부 확인 (관리종목, 환기종목 등)
val riskResult = RiskManager.evaluateRisk(currentStock) val riskResult = RiskManager.evaluateRisk(currentStock)
if (!riskResult.isSafe) { if (!riskResult.isSafe) {
print("-> ${stock.name}[${stock.code}] 검문소 탈락: ${riskResult.rejectReason}") print("-> ${stock.name}[${stock.code}] 리스크 검문소 탈락: ${riskResult.rejectReason} | ")
return@withTimeout return@withTimeout
} }
// 7. 계좌 예산 및 단가 정책 필터링
if (!isOk || (myCash > 10L && currentPrice > myCash) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) { if (!isOk || (myCash > 10L && currentPrice > myCash) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> [${stock.name}] 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ") print("-> [${stock.name}] 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소:${minPrice}, 최대:${maxPrice}] | ")
return@withTimeout return@withTimeout
} }
// -------------------------------------------------------------
// 🌟 [추가] 고도화된 사전 필터링 (검문소) // [3차 필터: 기술적 통계 분석 (변동성, 반등 주기, 지하실 회피)]
// -------------------------------------------------------------
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) // 1. 변동성 기반 현실적 기대수익률 예측
println("(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.3).toInt()}")
val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20)
val expectedProfitRate = val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0
((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0
// 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?) // 2. 동적 반등 통계 산출
val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0) val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0)
val isApproaching = tempAnalyzer.checkReboundApproaching( val isApproaching = tempAnalyzer.checkReboundApproaching(
candles = dailyData, candles = dailyData,
@@ -1433,117 +1462,89 @@ object AutoTradingManager {
dropThreshold = dailyStats.avgDropRate, dropThreshold = dailyStats.avgDropRate,
timeTolerance = dailyStats.timeTolerance timeTolerance = dailyStats.timeTolerance
) )
print("-> [${stock.name}] 필터링 ${dailyStats.avgReboundPeriod} ${dailyStats.avgDropRate} ${dailyStats.timeTolerance}") print("-> [${stock.name}] 반등통계(주기:${"%.1f".format(dailyStats.avgReboundPeriod)}일, 평균하락폭:${"%.1f".format(dailyStats.avgDropRate)}%, 오차:${"%.1f".format(dailyStats.timeTolerance)})")
val isSteadyUptrend = false //tempAnalyzer.checkSteadyUptrend(dailyData)
// 3. 우상향 추세(Trend Following) 판별 - 필요 시 활성화
val isSteadyUptrend = tempAnalyzer.checkSteadyUptrend(dailyData)
// 🌟 [수정] 조건 통합 (OR 조건) // 4. 기대수익률 조건 (예측 수익 또는 과거 평균 반등폭이 기준 이상인가?)
val isProfitable = val isProfitable = expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate ||
expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과 // 5. 진입 타이밍 조건 (통계적 반등 주기에 도달했거나, 안정적 우상향 추세인가?)
val isValidEntryTiming = val isValidEntryTiming = (dailyStats.isValid && isApproaching &&
(dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays &&
dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend
val currentAtr = tempAnalyzer.calculateATR(dailyData) val currentAtr = tempAnalyzer.calculateATR(dailyData)
if (!isProfitable || !isValidEntryTiming) { if (!isProfitable || !isValidEntryTiming) {
print( print("-> [${stock.name}] 조건 미달 필터링 (예측수익:${"%.1f".format(expectedProfitRate)}%, 주기:${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권:$isValidEntryTiming) | ")
"-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${ return@withTimeout
"%.1f".format(
dailyStats.avgReboundPeriod
)
}, 진입권: $isValidEntryTiming) | "
)
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
} }
val dropPrediction =
tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr)
// 6. [보완] 바닥 예측 및 지하실(추가 낙폭) 회피 로직
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr)
if (dropPrediction != null) { if (dropPrediction != null) {
// 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지 // (1) 부호 혼선 방지: 현재가와 예상 바닥가 간의 실제 추가 하락 여력(%) 직관적 계산
if (dropPrediction.remainingDropRate < -1.0 && !dropPrediction.isBottomZone) { val distanceToBottomPct = ((currentPrice - dropPrediction.expectedBottomPrice) / currentPrice) * 100.0
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 추가 하락 위험) | ")
// 바닥존에 들어오지 않았는데 바닥까지 1.5% 이상 추가 하락 여력이 남아있다면 진입 차단
if (distanceToBottomPct > 2.0 && !dropPrediction.isBottomZone) {
print("-> [${stock.name}] 지하실 주의 (예상 바닥까지 -${"%.1f".format(distanceToBottomPct)}% 추가 하락 여력) | ")
return@withTimeout return@withTimeout
} }
// 💡 [방어 로직 2 - 신규] 가격은 바닥권에 왔지만, 캔들에 브레이크(지지)가 걸렸는가? // (2) 바닥권 도달 시 하락을 멈추는 브레이크(지지 캔들/반등 시그널) 확인
if (dropPrediction.isBottomZone) { if (dropPrediction.isBottomZone) {
val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData) val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData)
if (!hasBrake) { if (!hasBrake) {
print("-> [${stock.name}] 바닥 가격 도달했으나, 브레이크(지지/거래량 진정) 미확인. 떨어지는 칼날 회피 | ") print("-> [${stock.name}] 바닥 가격 도달했으나 지지/브레이크 미확인 (떨어지는 칼날 회피) | ")
return@withTimeout // 브레이크가 없으면 매수 안 함! return@withTimeout
} }
} }
} }
if (KisSession.tradeConfig.isUpcomingDividend) { println("\n🔍 [사전 필터 통과 -> 정밀 분석 진입] ${stock.name} (${LocalTime.now()})")
var dividend = KisTradeService.fetchUpcomingDividend(stock.code).getOrNull()
if (dividend?.hasDividend == true) { // -------------------------------------------------------------
println("[${stock.name}] 배당락일 ${dividend.exDividendDate} : ${dividend.dividendAmount}") // [4차: 멀티 타임프레임(30분/주봉/월봉) 비동기 호출 & AI(RAG) 분석]
} else { // -------------------------------------------------------------
println("[${stock.name}] 배당 정보 없어서 분석 종료") val analyzer = coroutineScope {
return@withTimeout val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
delay(20)
val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
delay(20)
val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) }
delay(20)
TechnicalAnalyzer().apply {
this.daily = dailyData
delay(50)
this.min30 = min30.await()
delay(50)
this.weekly = weekly.await()
delay(50)
this.monthly = monthly.await()
} }
} else {
println("[${stock.name}] 배당 정보 무관 함.")
} }
println( if (analyzer.isValid()) {
"🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${ println(" [분석 시작] ${stock.name} (${LocalTime.now()} - 데이터 정합성 통과)")
"%.1f".format( RagService.processStock(
expectedProfitRate currentPrice,
analyzer,
stock.name,
stock.code
) { decision, isSuccess ->
callback(
decision?.apply { this.currentPrice = currentPrice },
isSuccess
) )
}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}, 진입권: $isValidEntryTiming)"
)
if (!isSafetyBeltStockCodes.contains(stock.code)) {
val analyzer = coroutineScope {
val min30 = async {
tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList())
}
delay(20)
val weekly =
async {
tradeService.fetchPeriodChartData(stock.code, "W", true)
.getOrDefault(emptyList())
}
delay(20)
val monthly =
async {
tradeService.fetchPeriodChartData(stock.code, "M", true)
.getOrDefault(emptyList())
}
delay(20)
TechnicalAnalyzer().apply {
this.daily = dailyData
delay(50)
this.min30 = min30.await()
delay(50)
this.weekly = weekly.await()
delay(50)
this.monthly = monthly.await()
}
}
if (analyzer.isValid()) {
println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
RagService.processStock(
currentPrice,
analyzer,
stock.name,
stock.code
) { decision, isSuccess ->
callback(
decision?.apply { this.currentPrice = currentPrice },
isSuccess
)
}
} else {
println("✅ [분석 실패] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
} }
} else { } else {
println("재무 안정성 부족 (캐시)") println("❌ [분석 실패] ${stock.name} (${LocalTime.now()} - 필수 캔들 데이터 누락)")
} }
println(" [분석 종료] ${stock.name} (${LocalTime.now()})") println("🏁 [분석 종료] ${stock.name} (${LocalTime.now()})")
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()