package service import TradingDecision import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.Serializable import model.CandleData import model.ConfigIndex import model.KisSession import model.RankingStock import model.RankingType import network.DartCodeManager import network.FinancialMapper import network.FinancialStatement import network.KisTradeService import util.MarketUtil import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.concurrent.atomic.AtomicLong import kotlin.collections.List import kotlin.math.* // service/AutoTradingManager.kt typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit object AutoTradingManager { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var discoveryJob: Job? = null // 모니터링을 위한 상태 변수 private val lastTickTime = AtomicLong(System.currentTimeMillis()) private var watchdogJob: Job? = null // 설정 상수 private const val MIN_RISE_RATE = 0.1 private const val MAX_RISE_RATE = 21.0 private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 fun isRunning(): Boolean = discoveryJob?.isActive == true private var remainingCandidates = mutableListOf() // private val processedCodes = mutableSetOf() // 중복 처리 방지용 (선택 사항) private val reanalysisList = mutableListOf() private val retryCountMap = mutableMapOf() /** * 자동 발굴 루프 시작 및 Watchdog 실행 */ fun startAutoDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { if (isRunning()) return // 1. 기존 Watchdog이 있다면 제거 후 새로 시작 watchdogJob?.cancel() watchdogJob = scope.launch { while (isActive) { delay(WATCHDOG_CHECK_INTERVAL) val now = System.currentTimeMillis() if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) { println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.") restartLoop(tradeService, callback) } } } // 2. 메인 루프 실행 runDiscoveryLoop(tradeService, callback) } suspend fun resumePendingSellOrders(tradeService: KisTradeService) { // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED)) pendingSells.forEach { item -> // 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치) val balance = tradeService.fetchIntegratedBalance().getOrNull() val holding = balance?.holdings?.find { it.code == item.code } if (holding != null && holding.quantity.toInt() > 0) { var final = MarketUtil.roundToTickSize(item.targetPrice) println("🔄 [재주문] ${item.name} (${item.code}) ${item.orderedPrice} ${final} 전날 미체결 매도 건 재주문 시도") // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 tradeService.postOrder( stockCode = item.code, qty = item.quantity.toString(), price = final.toLong().toString(), isBuy = false ).onSuccess { newOrderNo -> // 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지 DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo) println("✅ [재주문 완료] ${item.name}: $newOrderNo") }.onFailure { println("❌ [재주문 실패] ${item.name}: ${it.message}") } } else { // 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리 DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED) } delay(200) // API 호출 부하 방지 } } private fun runDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { discoveryJob = scope.launch { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") while (isActive) { try { lastTickTime.set(System.currentTimeMillis()) // 생존 신고 withTimeout(CYCLE_TIMEOUT) { println("⏱️ [Cycle Start] ${LocalTime.now()}") // [프로세스 1] 장 마감 및 잔고 체크 val now = LocalTime.now(ZoneId.of("Asia/Seoul")) //&& now.isBefore(LocalTime.of(15, 30)) if (now.isAfter(LocalTime.of(15, 30)) ) { // executeClosingLiquidation(tradeService) return@withTimeout } val balance = tradeService.fetchIntegratedBalance().getOrNull() val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } // [프로세스 2] 후보군 수집 if (remainingCandidates.isEmpty()) { val candidates: MutableList = fetchCandidates(tradeService).apply { println("후보군 총 개수 : $size") }.filter { !it.name.contains("호스팩", true) } .sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) } .toMutableList() if (reanalysisList.isNotEmpty()) { candidates.addAll(reanalysisList) } reanalysisList.clear() remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks }.distinctBy { it.code }) } else { println("미확인 데이터 ${remainingCandidates.size}") } // [프로세스 3] 종목별 순회 분석 var totalCount = remainingCandidates.size println("후보군 조건 충족 총 개수 : ${totalCount}") val iterator = remainingCandidates.iterator() while (iterator.hasNext()) { totalCount-- val stock = iterator.next() try { processSingleStock(stock, myCash, tradeService, callback) // 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거 } catch (e: Exception) { println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") // 오류 시 리스트에 남겨둘지, 제거할지 결정 // (심각한 에러면 remove하고 다음 루프에서 다시 받는게 안전) } finally { iterator.remove() } println("남은 후보군 개수 : ${totalCount}") delay(250) } println("⏱️ [Cycle End] ${LocalTime.now()}") } } catch (e: TimeoutCancellationException) { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") } catch (e: Exception) { println("⚠️ [Loop Error] ${e.message}") delay(3000) } waitForNextCycle(0.3) } } } fun addToReanalysis(stock: RankingStock) { val count = retryCountMap.getOrDefault(stock.code, 0) if (count < 2) { // 최대 2회까지만 재시도하여 무한 루프 방지 retryCountMap[stock.code] = count + 1 reanalysisList.add(stock) println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록") } } private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { try { val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) // 개별 종목 분석은 최대 2분으로 제한 withTimeout(120000L) { val corpInfo = DartCodeManager.getCorpCode(stock.code) if (corpInfo?.cName.isNullOrEmpty()) { print("-> 기업명을 못찾아서 제외 | ") return@withTimeout } callback(TradingDecision().apply { this.stockCode = stock.code this.confidence = -1.0 this.stockName = stock.name }, false) val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout val today = dailyData.lastOrNull() ?: null if (today == null) { print("-> 금일 금액 조회 실패 | ") return@withTimeout } val currentPrice = today.stck_prpr.toDouble() if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) { print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ") return@withTimeout } println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})") val analyzer = coroutineScope { val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) } TechnicalAnalyzer().apply { this.daily = dailyData this.min30 = min30.await() this.weekly = weekly.await() this.monthly = monthly.await() } } RagService.processStock(currentPrice,analyzer, stock.name, stock.code) { decision, isSuccess -> callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) } println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})") } } catch (e: Exception) { println("❌ [Stock Error] ${stock.name}: ${e.message}") } } private suspend fun fetchCandidates(tradeService: KisTradeService): List = coroutineScope { listOf( // async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, // async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, true).getOrDefault(emptyList()) }, ).awaitAll().flatten() } private fun restartLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { discoveryJob?.cancel() startAutoDiscoveryLoop(tradeService, callback) } private suspend fun waitForNextCycle(minutes: Double) { println("💤 대기 모드 진입...") val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...") delay(1000) } } private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { val activeTrades = DatabaseFactory.findAllMonitoringTrades() val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() activeTrades.forEach { trade -> try { if (!realHoldings.containsKey(trade.code)) { DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED) return@forEach } // 마감 정리 로직 (필요 시 주석 해제하여 사용) println("📢 [마감 정리 체크] ${trade.name}") } catch (e: Exception) { println("⚠️ [마감 에러] ${trade.name}: ${e.message}") } delay(200) } } fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null println("🛑 [AutoTrading] 자동 발굴 중단됨") } fun addStock(currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) { scope.launch { RagService.processStock(currentPrice,technicalAnalyzer,stockName, stockCode, result) } } fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) { if (!isRunning()) { println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") startAutoDiscoveryLoop(tradeService, callback) } else { } } } object FinancialAnalyzer { fun isSafetyBeltMet(fs: FinancialStatement): Boolean { val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만 val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상 val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함 return isDebtSafe && isLiquiditySafe && isNotDeficit } /** * [매수 고려] 우량 기업 요건 확인 * 모든 조건 충족 시 적극적인 분석(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 } /** * 종합 상태 반환 (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() } } data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) val shortTerm: Int, // 단기 (일봉/뉴스) val midTerm: Int, // 중기 (주봉/재무) val longTerm: Int // 장기 (월봉/펀더멘털) ) { override fun toString(): String { return """ ultraShort $ultraShort shortTerm $shortTerm midTerm $midTerm longTerm $longTerm """.trimIndent() } } @Serializable class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() var daily: List = emptyList() var min30: List = emptyList() fun isOverheatedStock(): Boolean { if (min30.size < 20 || daily.size < 20) return false val currentPrice = min30.last().stck_prpr.toDouble() // 1. 일봉 기준 이격도 체크 (20일 이평선 대비) val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val disparityDaily = (currentPrice / ma20Daily) * 100 // 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열) if (disparityDaily > 125.0) return true // 2. 분봉(30분봉) 기준 단기 급등 체크 val startPrice30 = min30.first().stck_oprc.toDouble() val riseRate30 = ((currentPrice - startPrice30) / startPrice30) * 100 // 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험 if (riseRate30 > 15.0) return true // 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑) val avgVol = min30.dropLast(3).map { it.cntg_vol.toDouble() }.average() val recentVol = min30.last().cntg_vol.toDouble() // 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음 if (recentVol > avgVol * 10) return true // 4. 볼린저 밴드 상단 이탈 강도 // ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인 return false } fun calculateScores( financialScore: Int // 재무제표 점수 (성장률 등 기반) ): InvestmentScores { // 1. 초단기 (분봉 + 에너지 지표 위주) val ultra = (calculateMFI(min30, 14) * 0.4 + calculateStochastic(min30) * 0.3 + (if(calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt() // 2. 단기 (일봉 추세 + OBV 에너지) val short = (calculateRSI(daily) * 0.3 + (if(calculateOBV(daily) > 0) 40 else 10) + (if(calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt() // 3. 중기 (주봉 + 재무 점수 혼합) val mid = (if(calculateChange(weekly) > 0) 40 else 10) + (financialScore * 0.6).toInt() // 4. 장기 (월봉 + 섹터/기업 펀더멘털) val long = (if(calculateChange(monthly) > 0) 50 else 0) + (financialScore * 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) ) } fun generateComprehensiveReport(): String { // [1] 단기 에너지 지표 계산 (최근 30분봉 기준) val obv = calculateOBV(min30) val mfi = calculateMFI(min30, 14) val adLine = calculateADLine(min30) // [2] 시계열별 가격 변동 및 추세 요약 val m10 = min30.takeLast(10) val change10 = calculateChange(m10) val change30 = calculateChange(min30) val changeDaily = calculateChange(daily.takeLast(2)) // 전일 대비 // [3] 이평선 및 가격 위치 val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average() val currentPrice = min30.last().stck_prpr.toDouble() val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish()) // [4] 거래량 강도 val avgVol30 = min30.map { it.cntg_vol.toLong() }.average() val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average() val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0 val atr = calculateATR(min30) val stochK = calculateStochastic(min30) val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() } return """ - 초/단타 종합 스코어: ${signal.compositeScore} / 100 - 초/단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"} - 초/단타 성공 확률 예측: ${signal.successProbPct}% - 초/단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반) - 초/단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배 - 초/단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원) - 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"} - 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동 - 30분 대비: ${ "%.2f".format(change30) }% 변동 - 10분 대비: ${ "%.2f".format(change10) }% 변동 - 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"} - OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } - MFI (자금 유입 지수): ${ "%.1f".format(mfi) } - A/D (누적 분산 라인): ${ "%.0f".format(adLine) } - 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준 - ATR (평균 변동폭): ${"%.0f".format(atr)}원 - 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 - 스토캐스틱(%K): ${"%.1f".format(stochK)} - 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준 - 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }} - 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }} - RSI(14): ${ "%.1f".format(calculateRSI(min30)) } """.trimIndent() } /** * ATR (Average True Range): 최근 변동 폭의 평균. 그래프의 '출렁임' 크기를 측정 */ fun calculateATR(candles: List, period: Int = 14): Double { val sub = candles.takeLast(period + 1) val trList = mutableListOf() 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() val tr = maxOf(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose)) trList.add(tr) } return trList.average() } /** * Stochastic (%K): 최근 가격 범위 내에서 현재가의 위치 (0~100) * 반복되는 파동(Ups and Downs)에서 현재가 고점인지 저점인지 판단 */ fun calculateStochastic(candles: List, period: Int = 14): Double { val sub = candles.takeLast(period) val highest = sub.maxOf { it.stck_hgpr.toDouble() } val lowest = sub.minOf { it.stck_lwpr.toDouble() } val current = sub.last().stck_prpr.toDouble() return if (highest != lowest) (current - lowest) / (highest - lowest) * 100 else 50.0 } private fun calculateChange(list: List): Double { 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 } private fun calculateRSI(list: List): 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 isDailyBullish(): Boolean { if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리 val currentPrice = daily.last().stck_prpr.toDouble() // 1. MA20 (한 달 생명선) 계산 val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() // 2. MA5 (단기 가속도) 계산 val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average() // 3. 방향성 (어제 MA5 vs 오늘 MA5) val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average() val isMa5Rising = ma5 > prevMa5 // [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주 return currentPrice > ma20 && isMa5Rising } fun calculateOBV(candles: List): 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() val currVol = candles[i].cntg_vol.toDouble() when { currClose > prevClose -> obv += currVol currClose < prevClose -> obv -= currVol } } return obv } /** * MFI (Money Flow Index) 계산 (기간: 보통 14일) */ fun calculateMFI(candles: List, period: Int = 14): Double { val subList = candles.takeLast(period + 1) var posFlow = 0.0 var negFlow = 0.0 for (i in 1 until subList.size) { val prevTypical = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3 val currTypical = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3 val moneyFlow = currTypical * subList[i].cntg_vol.toDouble() if (currTypical > prevTypical) posFlow += moneyFlow else if (currTypical < prevTypical) negFlow += moneyFlow } return if (negFlow == 0.0) 100.0 else 100 - (100 / (1+ (posFlow / negFlow))) } private fun calculateADLine(candles: List): Double { var ad = 0.0 candles.forEach { val high = it.stck_hgpr.toDouble(); val low = it.stck_lwpr.toDouble(); val close = it.stck_prpr.toDouble() val mfv = if (high != low) ((close - low) - (high - close)) / (high - low) else 0.0 ad += mfv * it.cntg_vol.toDouble() } return ad } fun clear() { monthly = emptyList() weekly = emptyList() daily = emptyList() min30 = emptyList() } } class ScalpingAnalyzer { companion object { private const val SMA_SHORT = 10 private const val SMA_LONG = 20 private const val RSI_WINDOW = 14 private const val VOL_WINDOW = 20 private const val VOL_SURGE_THRESHOLD = 1.5 private const val RSI_THRESHOLD = 50.0 private const val BB_LOWER_POS = 0.2 private const val BB_UPPER_POS = 0.8 private const val ATR_WINDOW = 14 private const val DEFAULT_SL_PCT = -1.5 private const val DEFAULT_TP_PCT = 1.5 private const val HIGH_SCORE_THRESHOLD = 80 } fun computeRSI(closes: List, window: Int = RSI_WINDOW): List { val rsi = mutableListOf() if (closes.size < window + 1) return rsi for (i in window until closes.size) { val gains = mutableListOf() val losses = mutableListOf() for (j in (i - window + 1) until i + 1) { val delta = closes[j] - closes[j - 1] if (delta > 0) gains.add(delta) else losses.add(abs(delta)) } val avgGain = gains.average() val avgLoss = losses.average() val rs = if (avgLoss > 0) avgGain / avgLoss else Double.POSITIVE_INFINITY rsi.add(100.0 - (100.0 / (1.0 + rs))) } return rsi } fun bollingerBands(closes: List, window: Int = SMA_LONG): Triple, List, List> { val sma = mutableListOf() val upper = mutableListOf() val lower = mutableListOf() for (i in window - 1 until closes.size) { val slice = closes.subList(i - window + 1, i + 1) val mean = slice.average() val std = sqrt(slice.map { (it - mean).pow(2.0) }.average()) * 2.0 sma.add(mean) upper.add(mean + std) lower.add(mean - std) } return Triple(upper, sma, lower) } fun analyze(candles: List, isDailyBullish: Boolean): ScalpingSignalModel { if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요") val closes = candles.map { it.close } val volumes = candles.map { it.volume } // 지표 계산 val sma10 = simpleMovingAverage(closes, SMA_SHORT) val sma20 = simpleMovingAverage(closes, SMA_LONG) val rsiList = computeRSI(closes) val volAvg = simpleMovingAverage(volumes, VOL_WINDOW) val volRatioList = volumes.mapIndexed { i, v -> if (i >= VOL_WINDOW) v / volAvg[i - VOL_WINDOW] else 0.0 } val (bbUpper, bbMiddle, bbLower) = bollingerBands(closes) val current = candles.last() val idx = candles.size - 1 val currentClose = current.close val sma10Now = if (sma10.size > 0) sma10.last() else 0.0 val sma20Now = if (sma20.size > 0) sma20.last() else 0.0 val rsiNow = if (rsiList.isNotEmpty()) rsiList.last() else 0.0 val volRatioNow = volRatioList.last() val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) { (currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last()) } else 0.5 val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high } val isBreakout = currentClose > nearHigh // [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인) val bodySize = abs(current.close - current.open) val lowerShadow = minOf(current.close, current.open) - current.low val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우 // 신호 조건 고도화 // 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수 // val maBull = currentClose > sma10Now && sma10Now > sma20Now val rsiBull = rsiNow > RSI_THRESHOLD val volSurge = volRatioNow > VOL_SURGE_THRESHOLD val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS val maBull = currentClose > sma10Now && sma10Now > sma20Now val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout // val buySignal = maBull && rsiBull && volSurge && bbGood val score = (if (maBull) 25 else 0) + (if (rsiBull) 15 else 0) + (if (isBreakout) 20 else 0) + // 돌파 에너지 가중치 (minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() + (if (bbGood) 10 else 0) + (if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수 // 위험도 (ATR proxy) val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 } val atrProxy = if (returns.size >= ATR_WINDOW) { returns.subList(returns.size - ATR_WINDOW, returns.size).average() } else 1.0 val riskLevel = when { abs(atrProxy) < 1 -> "Low" abs(atrProxy) < 2 -> "Medium" else -> "High" } // 성공 확률 & SL/TP val successProb = if (buySignal) 75.0 else 35.0 + (score / 100.0 * 20) val slPrice = currentClose * (1 + DEFAULT_SL_PCT / 100) val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100) val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT) return ScalpingSignalModel( currentPrice = currentClose, buySignal = buySignal, compositeScore = minOf(score.toInt(), 100), successProbPct = successProb, riskLevel = riskLevel, rsi = rsiNow, volRatio = volRatioNow, suggestedSlPrice = slPrice, suggestedTpPrice = tpPrice, riskRewardRatio = rrRatio ) } private fun simpleMovingAverage(values: List, window: Int): List { val sma = mutableListOf() for (i in window - 1 until values.size) { val slice = values.subList(i - window + 1, i + 1) sma.add(slice.average()) } return sma } } data class Candle( val timestamp: Long, val open: Double, val high: Double, val low: Double, val close: Double, val volume: Double ) data class ScalpingSignalModel( val currentPrice: Double, val buySignal: Boolean, val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수) val successProbPct: Double, // 성공 확률 추정 % val riskLevel: String, // "Low", "Medium", "High" val rsi: Double, val volRatio: Double, val suggestedSlPrice: Double, // 손절 가격 val suggestedTpPrice: Double, // 익절 가격 val riskRewardRatio: Double ) fun CandleData.toScalpingCandle(): Candle { // 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합 val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}" val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") // 2. 타임스탬프(Epoch Milliseconds) 계산 val timestamp = try { val ldt = LocalDateTime.parse(dateTimeStr, formatter) ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() } catch (e: Exception) { // 시간 파싱 실패 시 현재 시스템 시간 사용 System.currentTimeMillis() } // 3. String 필드들을 Double로 변환하여 Candle 객체 생성 return Candle( timestamp = timestamp, open = this.stck_oprc.toDoubleOrNull() ?: 0.0, high = this.stck_hgpr.toDoubleOrNull() ?: 0.0, low = this.stck_lwpr.toDoubleOrNull() ?: 0.0, close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할 volume = this.cntg_vol.toDoubleOrNull() ?: 0.0 ) } /** * 리스트 전체를 변환하는 유틸리티 */ fun List.toScalpingList(): List { return this.map { it.toScalpingCandle() } }