package service import AutoTradeItem import Defines.AUTOSELL import Defines.BLACKLISTEDSTOCKCODES import Defines.EMBEDDING_PORT import Defines.LLM_PORT import network.TradingDecision import TradingLogStore import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import getLlamaBinPath 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.ExecutionData import model.KisSession import model.RankingStock import model.RankingType import model.UnifiedBalance import network.DartCodeManager import network.FinancialStatement import network.KisAuthService import network.KisTradeService import network.KisWebSocketManager import network.RagService import network.StockUniverseLoader import org.jetbrains.skia.ImageFilter 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 CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 private const val ONE_STOCK_ALYSIS_TIME = 180000L fun isRunning(): Boolean = discoveryJob?.isActive == true private var remainingCandidates = mutableListOf() // private val processedCodes = mutableSetOf() // 중복 처리 방지용 (선택 사항) private val reanalysisList = mutableListOf() private val retryCountMap = mutableMapOf() var shouldShowFullWindow by mutableStateOf(false) var llmAnalyser by mutableStateOf(false) var llmNews by mutableStateOf(false) var tradeToken by mutableStateOf(false) var webSocketConnect by mutableStateOf(false) var testFlag = false fun startBackgroundScheduler() { scope.launch { while (isActive) { val now = LocalTime.now(ZoneId.of("Asia/Seoul")) if (now.isAfter(H08M30) && now.isBefore(H18) && !shouldShowFullWindow) { shouldShowFullWindow = true SystemSleepPreventer.wakeDisplay() } else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) { SystemSleepPreventer.sleepDisplay() } delay(60_000 * 3) // 1분마다 체크 } } } val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> if (isSuccess && completeTradingDecision != null) { // 1. 로그 저장소에 기록 (UI에서 이걸 읽음) TradingLogStore.addLog(completeTradingDecision) println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}") if (completeTradingDecision.confidence < 10) { addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) TradingLogStore.addLog(completeTradingDecision,"RETRY","분석 신뢰도 오류 인지로 재분석 대기열에 추가") }else if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) { var basePrice = completeTradingDecision.currentPrice var stockCode = completeTradingDecision.stockCode println("basePrice $basePrice") val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX) val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) fun resultCheck(completeTradingDecision :TradingDecision) { val weights = mapOf( "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 "profit" to 0.4, "safe" to 0.4 // 중장기 점수 비중 강화 ) val totalScore = ((completeTradingDecision.shortPossible() + append) * weights["short"]!!) + ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) + ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { var investmentGrade : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) val gradeRate = (1.0 - (investmentGrade.ordinal * 0.12)) val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt() maxBudget = maxBudget * gradeRate val calculatedQty = if (basePrice > 0) { (maxBudget / basePrice).toInt().coerceAtLeast(1) } else { 1 } // 5. 매수 실행 (계산된 finalMargin 전달) excuteTrade( decision = completeTradingDecision, orderQty = min(calculatedQty, maxQty).toString(), profitRate1 = finalMargin, investmentGrade = investmentGrade, ) } else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) { addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) TradingLogStore.addLog(completeTradingDecision,"RETRY","✋ [관망] 토탈 스코어[$totalScore] 또는 신뢰도[${completeTradingDecision.confidence}] 미달 이나 약간의 오차로 재분석 대기열에 추가") } else { TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") } } if (completeTradingDecision?.decision?.contains("매수") == true) { completeTradingDecision.decision = "BUY" } when (completeTradingDecision?.decision) { "BUY","매수" -> { append = buyWeight TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}") resultCheck(completeTradingDecision) } "SELL" -> { TradingLogStore.addLog(completeTradingDecision,"SELL","[$stockCode] 매도 추천 resultCheck: ${completeTradingDecision?.reason}") println("[$stockCode] 매도: ${completeTradingDecision?.reason}") } "HOLD" -> { append = 0.0 TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}") resultCheck(completeTradingDecision) } else -> { append = 0.0 println("[$stockCode] ${completeTradingDecision?.decision} resultCheck: ${completeTradingDecision?.reason}") } } } } } val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 var append = 0.0 fun getInvestmentGrade( ts: TradingDecision, totalScore: Double, confidence: Double ): InvestmentGrade { // 1. 기본 조건 충족 여부 if (totalScore < 68.0 || confidence < 70.0) { return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음) } // 2. 단기/중기/장기 패턴 기준 val ultraShort = ts.ultraShortScore val short = ts.shortTermScore val mid = ts.midTermScore val long = ts.longTermScore val shortAvg = listOf(ultraShort, short).average() // 초단기+단기 val midLongAvg = listOf(mid, long).average() // 중기+장기 return when { // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 shortAvg >= 85.0 && midLongAvg >= 80.0 -> if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 midLongAvg >= 75.0 && shortAvg >= 70.0 -> if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 shortAvg >= 75.0 && midLongAvg < 65.0 -> if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 shortAvg >= 70.0 && midLongAvg < 55.0 -> InvestmentGrade.LEVEL_1_SPECULATIVE // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) else -> if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK } } fun excuteTrade(decision: TradingDecision,orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { scope.launch { var basePrice = decision.currentPrice val tickSize = MarketUtil.getTickSize(basePrice) val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) var stockCode = decision.stockCode var stockName = decision.stockName val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice") TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo") val pRate = 0.4 val sRate = -1.5 var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues( ConfigIndex.PROFIT_INDEX) + tax)) val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 DatabaseFactory.saveAutoTrade(AutoTradeItem( orderNo = realOrderNo, code = stockCode, name = stockName, quantity = inputQty, profitRate = effectiveProfitRate, // 보정된 수익률 저장 stopLossRate = sRate, targetPrice = calculatedTarget, stopLossPrice = calculatedStop, status = "PENDING_BUY", isDomestic = true )) syncAndExecute(realOrderNo) TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") } .onFailure { println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") if (it.message?.contains("주문가능금액을 초과") == true) { AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) TradingLogStore.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가") } else { TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패") } } } } var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy -> scope.launch { val exec = ExecutionData(orderNo, code, price, qty, isBuy) executionCache[orderNo] = exec syncAndExecute(orderNo) } } val executionCache = mutableMapOf() val processingIds = mutableSetOf() // 주문번호 기준 잠금 suspend fun syncAndExecute(orderNo: String) { if (processingIds.contains(orderNo)) return processingIds.add(orderNo) try { val dbItem = DatabaseFactory.findByOrderNo(orderNo) val execData = executionCache[orderNo] if (dbItem != null && execData != null && execData.isFilled) { if (dbItem.status == TradeStatus.PENDING_BUY) { // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") KisTradeService.postOrder( stockCode = dbItem.code, qty = dbItem.quantity.toString(), price = finalTargetPrice.toLong().toString(), isBuy = false ).onSuccess { newSellOrderNo -> // 익절가 업데이트 및 상태 변경 DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") executionCache.remove(orderNo) }.onFailure { println("❌ 익절 주문 실패: ${it.message}") TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}") } } else if (dbItem.status == TradeStatus.SELLING) { println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리") DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) executionCache.remove(orderNo) } } } finally { processingIds.remove(orderNo) } } /** * 자동 발굴 루프 시작 및 Watchdog 실행 */ fun startAutoDiscoveryLoop() { 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() } } } // 2. 메인 루프 실행 runDiscoveryLoop(globalCallback) } suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. println("resumePendingSellOrders") balance.holdings.forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( holding.name, holding.code, "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" ) } else { if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ") // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 var targetPrice = holding.currentPrice.toDouble() targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") // TradingLogStore.addSellLog(holding.code,targetPrice.toString(),"SELL","🎊 보유 주식 매도 주문[예상수익 : ${holding.profitRate}] ") tradeService.postOrder( stockCode = holding.code, qty = holding.availOrderCount, price = targetPrice.toInt().toString(), isBuy = false ).onSuccess { newOrderNo -> // 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지 // DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo) println("✅ [재주문 완료] ${holding.name}: $newOrderNo") TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 보유 주식 매도 주문 완료[예상수익 : ${holding.profitRate}] " ) }.onFailure { TradingLogStore.addSellLog( holding.code, targetPrice.toString(), "SELL", "🎊 보유 주식 매도 주문 실패[${it.message}] " ) } } else { TradingLogStore.addAnalyzer( "보유주식[${holding.name}]", holding.code, "수익률 미달 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" ) } delay(200) // API 호출 부하 방지 } } } var isSystemReadyToday = false var isSystemCleanedUpToday = false private var lastRetryTime = 0L val binPath = getLlamaBinPath() suspend fun tryRefreshToken() { try { // 2분 간격 재시도 로직 (처음 실행 시에는 lastRetryTime이 0이므로 즉시 실행) if (currentTimeMillis - lastRetryTime >= 2 * 60 * 1000L) { lastRetryTime = currentTimeMillis println("🌅 [System] 오전 8시 업무 시작 준비 시도...") SystemSleepPreventer.wakeDisplay() // 모니터 깨우기 val authSuccess = KisAuthService.refreshAllTokens() val wsSuccess = KisTradeService.refreshWebsocketKey() if (authSuccess && wsSuccess) { println("✅ [System] 토큰 갱신 성공. AI 서버를 기동합니다.") // 서버 시작 로직 실행 (Main.kt에 있던 로직 활용) val config = KisSession.config // LLM 서버 시작 (설정된 모델 경로 사용) if (config.modelPath.isNotEmpty()) { LlamaServerManager.startServer(binPath, config.modelPath,port = LLM_PORT) } if (config.embedModelPath.isNotEmpty()) { LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT) } KisWebSocketManager.connect() isSystemReadyToday = true shouldShowFullWindow = true } else { println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") } } } catch (e: Exception) {} } var onMarketClosed: (() -> Unit)? = null var now = LocalTime.now(ZoneId.of("Asia/Seoul")) var currentTimeMillis = System.currentTimeMillis() var waitTime = 0.2 val H18 = LocalTime.of(18, 0) val H08M35 = LocalTime.of(8, 35) val H08M30 = LocalTime.of(8, 30) private fun runDiscoveryLoop(callback: TradingDecisionCallback) { discoveryJob = scope.launch { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") while (isActive) { try { now = LocalTime.now(ZoneId.of("Asia/Seoul")) currentTimeMillis = System.currentTimeMillis() lastTickTime.set(System.currentTimeMillis()) // 생존 신고 when { now.isAfter(H18) || now.isBefore(H08M35) -> { prepareMarketOpen(now) } now.isBefore(H18) && now.isAfter(H08M35) -> { waitTime = 0.2 if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (isSystemReadyToday) { println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") isSystemReadyToday = false KisWebSocketManager.disconnect() tryRefreshToken() } } } withTimeout(CYCLE_TIMEOUT) { println("⏱️ [Cycle Start] ${LocalTime.now()}") if (now.isAfter(H18)) { executeClosingLiquidation(KisTradeService) } else { executeMarketLoop() } } } // // //장외 // now.isAfter(H16) || now.isBefore(H08M35) -> { // finalizeMarketClose(now) // } else ->{ waitTime = 3.0 } } } catch (e: TimeoutCancellationException) { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") } catch (e: Exception) { println("⚠️ [Loop Error] ${e.message}") delay(1500) } waitForNextCycle(waitTime) } } } suspend fun prepareMarketOpen(now : LocalTime) { if (now.isAfter(H18) || now.isBefore(H08M30)) { println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.") onMarketClosed?.invoke() KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) LlamaServerManager.stopAll() // AI 서버 완전 종료 TradingLogStore.clear() isSystemReadyToday = false shouldShowFullWindow = false stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) } else if (now.isAfter(H08M30) && now.isBefore(H08M35) && !isSystemReadyToday) { if (MarketUtil.canTradeToday()) { SystemSleepPreventer.wakeDisplay() shouldShowFullWindow = true println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true) } else { println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.") delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여 } } } var loadedTops = mutableListOf>() fun poll100Stocks(): List> { val count = minOf(loadedTops.size, 100) if (count == 0) return emptyList() // 앞의 100개를 복사 val batch = loadedTops.subList(0, count).toList() // 원본에서 삭제 (이 작업이 큐의 pop/remove 역할을 합니다) loadedTops.subList(0, count).clear() return batch } suspend fun checkBalance() : UnifiedBalance? { val balance = KisTradeService.fetchIntegratedBalance().getOrNull() if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } return balance } suspend fun executeMarketLoop() { val balance = checkBalance() if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } 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 } if (remainingCandidates.isEmpty()) { if (loadedTops.size < 100) { loadedTops.addAll(StockUniverseLoader.loadUniverse()) loadedTops.shuffle() println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.") } poll100Stocks().forEach { (code, name) -> addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name)) } val candidates: MutableList = fetchCandidates(KisTradeService).apply { }.filter { val rate = it.prdy_ctrt.toDouble() val corpInfo = DartCodeManager.getCorpCode(it.code) val isOk = (rate > 0 && rate < 15) || (rate < 0 && rate > -15) if (corpInfo?.cName.isNullOrEmpty()) { false } else { isOk } } .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 && it.code !in executionCache.values.map { it.code } && it.code !in failList} .distinctBy { it.code }) } else { println("미확인 데이터 ${remainingCandidates.size}") } var totalCount = remainingCandidates.size println("후보군 조건 충족 총 개수 : ${totalCount}") val iterator = remainingCandidates.iterator() while (iterator.hasNext()) { totalCount-- val stock = iterator.next() if (BLACKLISTEDSTOCKCODES.contains(stock.code)){ println("❌ 차단 처리된 주식 : ${stock.name}") } else { try { processSingleStock(stock, myCash, KisTradeService, globalCallback) } catch (e: Exception) { println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") } finally { iterator.remove() } println("남은 후보군 개수 : ${totalCount}") delay(100) } sellSchedule() } println("⏱️ [Cycle End] ${LocalTime.now()}") } private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장 suspend fun sellSchedule() { val now = LocalTime.now() val currentMinute = now.minute println("매도 스케줄 체크") if (now.hour == 9 && (currentMinute == 1 || currentMinute == 15 || currentMinute == 40)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer(" - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true) println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") checkBalance() lastForceCheckMinute = currentMinute // 실행 완료 기록 } } // else if(now.hour % 2 == 1 && (currentMinute == 43)) { // TradingLogStore.addAnalyzer(" - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true) // println("⏰ [강제 스케줄 실행] 오전 ${now.hour}시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") // checkBalance() // lastForceCheckMinute = currentMinute // 실행 완료 기록 // } } suspend fun finalizeMarketClose(now: LocalTime) { when { (AutoTradingManager.now.hour == 0 && AutoTradingManager.now.minute == 0 && (isSystemReadyToday || isSystemCleanedUpToday)) -> { waitTime = 10.0 isSystemReadyToday = false isSystemCleanedUpToday = false } (AutoTradingManager.now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> { waitTime = 3.0 if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { KisWebSocketManager.disconnect() tryRefreshToken() } } (AutoTradingManager.now.isAfter(LocalTime.of(18, 20))) -> { try { waitTime = 5.0 println("current SystemCleanedUpToday is $isSystemCleanedUpToday") if (!isSystemCleanedUpToday) { println("🌙 [System] 업무 종료 및 자원 정리 시작...") SystemSleepPreventer.sleepDisplay() // 모니터 끄기 KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) // 즉시 닫기 if (LlamaServerManager.stopAll()) { isSystemCleanedUpToday = true } } println("✅ [System] 오늘의 모든 정리가 완료되었습니다.") } catch (e: Exception) { } } (AutoTradingManager.now.isAfter(LocalTime.of(18, 15)) && AutoTradingManager.now.minute % 15 == 0) -> { try { waitTime = 5.0 SystemSleepPreventer.sleepDisplay() // 모니터 끄기 } catch (e: Exception) { } } else -> { waitTime = 5.0 } } } fun addToReanalysis(stock: RankingStock) { val count = retryCountMap.getOrDefault(stock.code, 0) if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지 retryCountMap[stock.code] = count + 1 reanalysisList.add(stock) // println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록") } } val failList = arrayListOf() 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(ONE_STOCK_ALYSIS_TIME) { 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) { failList.add(stock.code) 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() } } if (analyzer.isValid()) { 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() { discoveryJob?.cancel() startAutoDiscoveryLoop() } private suspend fun waitForNextCycle(minutes: Double) { println("💤 대기 모드 진입... $minutes") val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) try { BrowserManager.closeIfIdle(0) // 즉시 닫기 } catch (e: Exception) { } while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...$minutes") delay(if(minutes > 3.0 ) 10000 else 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.EXPIRED) return@forEach } // 마감 정리 로직 (필요 시 주석 해제하여 사용) println("📢 [마감 정리 체크] ${trade.name}") } catch (e: Exception) { println("⚠️ [마감 에러] ${trade.name}: ${e.message}") } delay(200) } } fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null println("🛑 [AutoTrading] 자동 발굴 중단됨") scope.launch { onMarketClosed?.invoke() println("💤 대기 모드 진입... $5.0") val endWait = System.currentTimeMillis() + (5.0 * 60 * 1000L) BrowserManager.closeIfIdle(0) // 즉시 닫기 while (System.currentTimeMillis() < endWait) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...") delay(if(5.0 > 3.0 ) 10000 else 1000) } KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) LlamaServerManager.stopAll() // AI 서버 완전 종료 TradingLogStore.clear() onMarketClosed?.invoke() } } fun addStock(currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) { scope.launch { RagService.processStock(currentPrice,technicalAnalyzer,stockName, stockCode, result) } } fun checkAndRestart() { if (!isRunning()) { println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") startAutoDiscoveryLoop() } 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 // 당기순이익은 일단 흑자여야 함 val isNotCrashing = fs.netIncomeGrowth > -40.0 return isDebtSafe && isLiquiditySafe && isNotDeficit && isNotCrashing } /** * [매수 고려] 우량 기업 요건 확인 * 모든 조건 충족 시 적극적인 분석(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 } fun toString(fs : FinancialStatement): String { var buffer = StringBuffer() val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만 val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상 val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함 val isNotCrashing = fs.netIncomeGrowth > -40.0 if ((isDebtSafe && isLiquiditySafe && isNotDeficit) == false) { if (!isDebtSafe)buffer.appendLine( "부채비율 200% 이상") if (!isLiquiditySafe)buffer.appendLine( "당좌비율 80% 미만") if (!isNotDeficit)buffer.appendLine( "당기순이익 적자") if (!isNotCrashing) { buffer.appendLine("당기순이익 급감(${String.format("%.1f", fs.netIncomeGrowth)}%)") } buffer.appendLine("최소 기준 미달") } else { buffer.appendLine("최소 기준 충족") } 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 // 본업(영업이익)이 흑자 if ((highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy) == false) { if(!highProfitability) buffer.appendLine( "ROE 10% 미만") if(!strongGrowth) buffer.appendLine( "이익 성장률 15% 미만") if(!verySafeDebt) buffer.appendLine( "부채비율 100% 이상 (안전성 미달)") if(!goodLiquidity) buffer.appendLine( "당좌비율 120% 이하 (여유 없음)") if(!businessHealthy) buffer.appendLine( "본업(영업이익)이 적자") buffer.appendLine("재무 건전성 및 성장성 미달") } else { buffer.appendLine("재무 건전성 및 성장성 충족") } return buffer.toString() } /** * 종합 상태 반환 (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 """ 평점 : ${avg()} 초단 : $ultraShort 단기 : $shortTerm 중기 : $midTerm 장기 : $longTerm """.trimIndent() } fun avg() = listOf(ultraShort, shortTerm, midTerm, longTerm).average() } @Serializable class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() var daily: List = emptyList() var min30: List = emptyList() fun isValid() = listOf(min30,monthly, weekly,daily).filter { it.size > 0 }.size == 4 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. 초단기 (분봉 + 에너지 지표 위주) var ultra = (calculateMFI(min30, 14) * 0.4 + calculateStochastic(min30) * 0.3 + (if(calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt() // 2. 단기 (일봉 추세 + OBV 에너지) var short = (calculateRSI(daily) * 0.3 + (if(calculateOBV(daily) > 0) 40 else 10) + (if(calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt() // 3. 중기 (주봉 + 재무 점수 혼합) var mid = (if(calculateChange(weekly) > 0) 40 else 10) + (financialScore * 0.6).toInt() // 4. 장기 (월봉 + 섹터/기업 펀더멘털) var long = (if(calculateChange(monthly) > 0) 50 else 0) + (financialScore * 0.5).toInt() // 1. 일봉 이격도 과열 체크 (20일 이평선 기준) if (daily.size >= 20) { val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val currentPrice = daily.last().stck_prpr.toDouble() val disparityDaily = (currentPrice / ma20Daily) * 100 if (disparityDaily > 115.0) { // 20일선보다 15% 이상 떠 있으면 감점 시작 val penalty = ((disparityDaily - 115.0) * 0.5).toInt() // 초과분 1%당 2점 감점 short -= penalty ultra -= (penalty / 2) // 초단기에도 영향 println("⚠️ [과열 감점] 일봉 이격도(${String.format("%.1f", disparityDaily)}%): -${penalty}점") } } // 2. 주봉 급등 체크 (최근 3주간의 상승폭) if (weekly.size >= 3) { val weeklyChange = calculateChange(weekly.takeLast(3)) if (weeklyChange > 30.0) { // 3주간 30% 이상 급등 시 mid -= 10 short -= 5 println("⚠️ [과열 감점] 주봉 급등(${String.format("%.1f", weeklyChange)}%): -10점") } } 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 ma5Daily = if (candles.size >= 5) candles.takeLast(5).map { it.close.toDouble() }.average() else currentClose val dailyDisparity = (currentClose / ma5Daily) * 100 // 과열 기준 정의 val isOverheated = dailyDisparity > 110.0 // 일봉 5일선 대비 10% 이상 이격 시 과열로 간주 // 매수 신호 조건에 과열 방지 추가 val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout && !isOverheated 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() } } enum class InvestmentGrade( val displayName: String, val description: String, val shortWeight: Double = 0.0, val midWeight: Double = 0.0, val longWeight: Double = 0.0, val profitGuide: ConfigIndex, val buyGuide: ConfigIndex, val allocationRate: ConfigIndex, ) { LEVEL_5_STRONG_RECOMMEND( displayName = "최상급 추천", description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", shortWeight = 1.0, midWeight = 1.0, longWeight = 1.0, profitGuide = ConfigIndex.GRADE_5_PROFIT, buyGuide = ConfigIndex.GRADE_5_BUY, allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE, ), LEVEL_4_BALANCED_RECOMMEND( displayName = "균형 추천", description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", shortWeight = 0.8, midWeight = 1.0, longWeight = 1.0, profitGuide = ConfigIndex.GRADE_4_PROFIT, buyGuide = ConfigIndex.GRADE_4_BUY, allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE, ), LEVEL_3_CAUTIOUS_RECOMMEND( displayName = "보수적 추천", description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", shortWeight = 0.6, midWeight = 1.0, longWeight = 1.0, profitGuide = ConfigIndex.GRADE_3_PROFIT, buyGuide = ConfigIndex.GRADE_3_BUY, allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE, ), LEVEL_2_HIGH_RISK( displayName = "고위험 추천", description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", shortWeight = 1.0, midWeight = 0.4, longWeight = 0.4, profitGuide = ConfigIndex.GRADE_2_PROFIT, buyGuide = ConfigIndex.GRADE_2_BUY, allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE, ), LEVEL_1_SPECULATIVE( displayName = "순수 공격적 선택", description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", shortWeight = 1.0, midWeight = 0.2, longWeight = 0.2, profitGuide = ConfigIndex.GRADE_1_PROFIT, buyGuide = ConfigIndex.GRADE_1_BUY, allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE, ) }