package service import TradingDecision import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData import model.RankingType import network.KisTradeService import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlin.collections.List import kotlin.math.* // service/AutoTradingManager.kt typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit object AutoTradingManager { private val scope = CoroutineScope(Dispatchers.Default) val targetStocks = mutableListOf>() // 자동 발굴 루프 제어용 Job private var discoveryJob: Job? = null fun startAutoDiscoveryLoop( tradeService: KisTradeService, callback: TradingDecisionCallback ) { if (discoveryJob?.isActive == true) return discoveryJob = scope.launch { println("🚀 [AutoTrading] 5분 주기 자동 발굴 시작") while (discoveryJob?.isActive == true) { try { // 1. [체크] 현재 잔고 및 보유 종목 조회 val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val myHoldings = balanceResult?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val myCash = balanceResult?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L println("💰 보유 현금: ${String.format("%,d", myCash)}원 | 보유 종목 수: ${myHoldings.size}") // 2. 랭킹 데이터 가져오기 // 1. 랭킹 데이터 가져오기 (비동기) val volRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) } val riseRankDeferred = async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) } // 거래대금(Amount) 상위 추가 val amountRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) } val volumePowerDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) } val volList = volRankDeferred.await() val riseList = riseRankDeferred.await() val amountList = amountRankDeferred.await() val volumeList = volumePowerDeferred.await() // (C) 거래대금 상위 종목 필터링 (시장의 주도주) val amountCandidates = amountList .filter { stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 rate in 1.0..15.0 // 너무 과열되지 않은 주도주 } val volCandidates = volList .filter { stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 rate in 1.0..18.0 // 0% 초과 20% 이하 } // (B) 상승률 상위 종목 중: 너무 급등한(20% 초과) 종목은 제외하고, 적당히 오르고 있는 종목만 필터링 -> 상위 10개 // 보통 상승률 랭킹은 상한가(30%)부터 내려오므로, 앞쪽의 급등주를 건너뛰어야 함 val riseCandidates = riseList .filter { stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 rate in 2.0..18.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간 } val volumeCandidates = volumeList .filter { stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 rate in 1.0..18.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간 } // 3. 리스트 합치기 (중복 제거) val candidates = (volCandidates + riseCandidates + amountCandidates + volumeCandidates).distinctBy { it.code } println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...") candidates.forEach { stock -> // [조건 1] 이미 보유한 종목 제외 if (myHoldings.contains(stock.code)) return@forEach val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0 // [조건 2] 최소 1주 매수 가능 여부 if (currentPrice > myCash || currentPrice > 5000) return@forEach callback(TradingDecision().apply { this.stockCode = stock.code this.confidence = -1.0 this.stockName = stock.name }, false) // 3. 일봉 데이터 조회 (필터링 용도 + TechnicalAnalyzer 입력용) val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true) val dailyData = dailyResult.getOrNull() val todayCandle = dailyData?.lastOrNull() if (dailyData != null && todayCandle != null) { val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0 val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0 if (open > 0) { val riseRate = (current - open) / open * 100 // [조건 3] 상승 중(양봉)이면서 20% 이하 상승 if (riseRate > 0 && riseRate <= 20.0) { println("✨ [발굴] ${stock.name} (+${String.format("%.1f", riseRate)}%) -> 데이터 수집 및 분석") // [핵심 수정] AI 분석 전 필요한 차트 데이터(30분, 주봉, 월봉)를 모두 가져와 TechnicalAnalyzer에 주입 // 비동기로 동시에 요청하여 속도 향상 val min30Def = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } val weekDef = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } val monthDef = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) } val min30Data = min30Def.await() val weeklyData = weekDef.await() val monthlyData = monthDef.await() // TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수) val t = TechnicalAnalyzer() t.daily = dailyData t.weekly = weeklyData t.monthly = monthlyData t.min30 = min30Data // 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지) RagService.processStock(t,stock.name, stock.code) { decision, isSuccess -> if (decision != null) { decision.stockName = stock.name decision.currentPrice = current // 차트에서 확인한 최신 현재가 주입 } callback(decision, isSuccess) // DashboardScreen으로 전달 } // 분석 후 잠시 대기 (서버 부하 조절) delay(2000) } } } delay(300) // 종목 간 API 호출 간격 } // --- 10초 주기 로그 대기 로직 시작 --- val waitMinutes = 3 val totalWaitMillis = waitMinutes * 60 * 1000L val tickMillis = 10 * 1000L var currentWait = 0L println("💤 사이클 종료. ${waitMinutes}분 대기...") println("✅ 이번 사이클 분석 완료.") while (currentWait < totalWaitMillis && discoveryJob?.isActive == true) { delay(tickMillis) currentWait += tickMillis val leftSec = (totalWaitMillis - currentWait) / 1000 // 1분 단위 혹은 10초 단위로 자유롭게 로그 조절 가능 if (leftSec % 30 == 0L || leftSec <= 30) { println("📡 [AutoTrading] 시스템 정상 작동 중... (다음 분석 ${leftSec}초 전)") } } } catch (e: Exception) { println("⚠️ 루프 오류: ${e.message}") delay(10000) // 오류 발생 시 10초 후 재시도 } } } } // 루프 중단 함수 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) } } private fun executeOrder(code: String, type: String) { // 실제 증권사 API 호출 로직 (한국투자증권, 키움 등) println("🔥 [주문 집행] $code $type 완료") } } class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() var daily: List = emptyList() var min30: List = emptyList() data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) val shortTerm: Int, // 단기 (일봉/뉴스) val midTerm: Int, // 중기 (주봉/재무) val longTerm: Int // 장기 (월봉/펀더멘털) ) 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() } }