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 model.CandleData import model.RankingStock import model.RankingType import network.DartCodeManager import network.KisTradeService 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 = 15.0 private const val CYCLE_TIMEOUT = 10 * 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 /** * 자동 발굴 루프 시작 및 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) } 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] 후보군 수집 val candidates = fetchCandidates(tradeService).apply { println("후보군 총 개수 : $size") } .filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE } .filter { it.code !in myHoldings && it.code !in pendingStocks } .distinctBy { it.code } .apply { println("후보군 조건 충족 총 개수 : $size") } // [프로세스 3] 종목별 순회 분석 candidates.forEach { stock -> try { lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고 processSingleStock(stock, myCash, tradeService, callback) } catch (e: Exception) { }finally { delay(300) } } println("⏱️ [Cycle End] ${LocalTime.now()}") } } catch (e: TimeoutCancellationException) { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") } catch (e: Exception) { println("⚠️ [Loop Error] ${e.message}") delay(10000) } waitForNextCycle(3) } } } private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { try { // 개별 종목 분석은 최대 2분으로 제한 withTimeout(120000L) { val corpInfo = DartCodeManager.getCorpCode(stock.code) if (corpInfo?.cName.isNullOrEmpty()) { // println("⏭️ [제외] ${stock.name}: 법인명 정보를 찾을 수 없음") return@withTimeout } val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout val today = dailyData.lastOrNull() ?: return@withTimeout val currentPrice = today.stck_prpr.toDouble() if (currentPrice > myCash || currentPrice > 15000 || currentPrice < 900) return@withTimeout println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})") callback(TradingDecision().apply { this.stockCode = stock.code this.confidence = -1.0 this.stockName = stock.name }, false) 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(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.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: Int) { println("💤 대기 모드 진입...") val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 delay(10000) } } 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(technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) { scope.launch { RagService.processStock(technicalAnalyzer,stockName, stockCode, result) } } fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) { if (!isRunning()) { println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") startAutoDiscoveryLoop(tradeService, callback) } else { } } } data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) val shortTerm: Int, // 단기 (일봉/뉴스) val midTerm: Int, // 중기 (주봉/재무) val longTerm: Int // 장기 (월봉/펀더멘털) ) class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() var daily: List = emptyList() var min30: List = emptyList() 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() } }