From 6d340b7dc04ed33ab2719fd9394458ce82d4bd24 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 13 Mar 2026 10:41:10 +0900 Subject: [PATCH] .... --- src/main/kotlin/Main.kt | 4 +- src/main/kotlin/network/KisAuthService.kt | 2 +- .../kotlin/network/KisWebSocketManager.kt | 18 +- src/main/kotlin/service/AutoTradingManager.kt | 243 +++++++++++++----- src/main/kotlin/service/LlamaServerManager.kt | 34 ++- .../kotlin/service/SystemSleepPreventer.kt | 79 +++++- src/main/kotlin/ui/DashboardScreen.kt | 2 +- src/main/kotlin/ui/SettingsScreen.kt | 2 +- 8 files changed, 307 insertions(+), 77 deletions(-) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 4cb60fe..e8f2071 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -41,6 +41,7 @@ import service.LlamaServerManager import network.NewsService import org.jetbrains.exposed.sql.selectAll import service.AutoTradingManager +import service.AutoTradingManager.isSystemCleanedUpToday import service.SystemSleepPreventer import service.TradingDecisionCallback import ui.DashboardScreen @@ -151,7 +152,8 @@ fun main() = application { onAuthSuccess = { // 2. 설정 및 인증 완료 시점의 처리 val config = KisSession.config - + AutoTradingManager.isSystemReadyToday = true + AutoTradingManager.isSystemCleanedUpToday = false // LLM 서버 시작 (설정된 모델 경로 사용) if (config.modelPath.isNotEmpty()) { LlamaServerManager.startServer(binPath, config.modelPath,port = 8080) diff --git a/src/main/kotlin/network/KisAuthService.kt b/src/main/kotlin/network/KisAuthService.kt index c0cfe60..b6c1ba9 100644 --- a/src/main/kotlin/network/KisAuthService.kt +++ b/src/main/kotlin/network/KisAuthService.kt @@ -19,7 +19,7 @@ import model.TokenRequest import model.TokenResponse import java.time.LocalDateTime -class KisAuthService { +object KisAuthService { private val client = HttpClient(CIO) { install(ContentNegotiation) { json(Json { diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt index d64e06c..ca2f0ed 100644 --- a/src/main/kotlin/network/KisWebSocketManager.kt +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -14,7 +14,7 @@ import model.RealTimeTrade import util.AesCrypto import java.util.concurrent.atomic.AtomicBoolean -class KisWebSocketManager { +object KisWebSocketManager { private val client = HttpClient { install(WebSockets) { pingInterval = 20_000 // 20초마다 표준 웹소켓 핑 전송 (서버-클라이언트 연결 유지 도움) @@ -23,7 +23,7 @@ class KisWebSocketManager { private var session: DefaultClientWebSocketSession? = null private val isConnected = AtomicBoolean(false) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + private var connectJob: Job? = null // 콜백 리스너 var onPriceUpdate: ((RealTimeTrade) -> Unit)? = null @@ -32,8 +32,8 @@ class KisWebSocketManager { suspend fun connect() { if (isConnected.get()) return val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:31000" else "ops.koreainvestment.com:21000" - - scope.launch { + connectJob?.cancelAndJoin() + connectJob = scope.launch { while (isActive) { // 재연결을 위한 루프 추가 try { client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") { @@ -192,4 +192,14 @@ class KisWebSocketManager { activeSubscriptions.addAll(requiredCodes) } + suspend fun disconnect() { + println("🔌 웹소켓 연결 종료 중...") + isConnected.set(false) + connectJob?.cancelAndJoin() // 루프 자체를 중단 + session?.close() + session = null + connectJob = null + println("🛑 웹소켓 완전 종료") + } + } \ No newline at end of file diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index c451dc8..de6dc42 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -1,6 +1,7 @@ package service import TradingDecision +import getLlamaBinPath import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -23,7 +24,9 @@ import model.UnifiedBalance import network.DartCodeManager import network.FinancialMapper import network.FinancialStatement +import network.KisAuthService import network.KisTradeService +import network.KisWebSocketManager import util.MarketUtil import java.time.LocalDateTime import java.time.LocalTime @@ -116,83 +119,196 @@ object AutoTradingManager { 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 = 8080) + } + if (config.embedModelPath.isNotEmpty()) { + LlamaServerManager.startServer(binPath, config.embedModelPath, port = 8081) + } + KisWebSocketManager.connect() + isSystemReadyToday = true + } else { + println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") + } + } + } catch (e: Exception) {} + } + + var now = LocalTime.now(ZoneId.of("Asia/Seoul")) + var currentTimeMillis = System.currentTimeMillis() + var waitTime = 0.2 private fun runDiscoveryLoop(tradeService: KisTradeService, 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()) // 생존 신고 - - 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, 20)) ) { - executeClosingLiquidation(tradeService) - return@withTimeout - } -// addToReanalysis(RankingStock(mksc_shrn_iscd = ,hts_kor_isnm = )) - val balance = tradeService.fetchIntegratedBalance().getOrNull() - - balance?.let { resumePendingSellOrders(tradeService,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 } - // [프로세스 2] 후보군 수집 - if (remainingCandidates.isEmpty()) { - val candidates: MutableList = fetchCandidates(tradeService).apply { - println("후보군 총 개수 : $size") - }.filter { - val rate = it.prdy_ctrt.toDouble() - val corpInfo = DartCodeManager.getCorpCode(it.code) - val isOk = (rate > 0 && rate < 15) || (rate < 0 && rate > -15) -// if (isOk) {println("${it.name} : ${it.prdy_ctrt}")} - if (corpInfo?.cName.isNullOrEmpty()) { - false - }else { - isOk +// if (now.minute % 5 == 0) { +// SystemSleepPreventer.sleepDisplay() +// } else { +// SystemSleepPreventer.wakeDisplay() +// } + when { + //장중 + now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> { + waitTime = 0.2 + if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { + // 토큰 중 하나라도 만료 5분 전이거나 비어있다면 다시 준비 상태로 전환 + if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { + if (isSystemReadyToday) { + println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") + isSystemReadyToday = false + KisWebSocketManager.disconnect() + tryRefreshToken() + } } } - .filter { !it.name.contains("호스팩", true) } - .sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) } - .toMutableList() + withTimeout(CYCLE_TIMEOUT) { + println("⏱️ [Cycle Start] ${LocalTime.now()}") - if (reanalysisList.isNotEmpty()) { - candidates.addAll(reanalysisList.asReversed()) + val now = LocalTime.now(ZoneId.of("Asia/Seoul")) + if (now.isAfter(LocalTime.of(15, 20))) { + executeClosingLiquidation(tradeService) + return@withTimeout + } + + val balance = tradeService.fetchIntegratedBalance().getOrNull() + balance?.let { resumePendingSellOrders(tradeService, 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 } + // [프로세스 2] 후보군 수집 + if (remainingCandidates.isEmpty()) { + val candidates: MutableList = fetchCandidates(tradeService).apply { + println("후보군 총 개수 : $size") + }.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.asReversed()) + } + 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()) { + if (now.minute % 2 == 0) { +// SystemSleepPreventer.sleepDisplay() + } else { +// SystemSleepPreventer.wakeDisplay() + } + totalCount-- + val stock = iterator.next() + try { + processSingleStock(stock, myCash, tradeService, callback) + } catch (e: Exception) { + println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") + } finally { + iterator.remove() + } + println("남은 후보군 개수 : ${totalCount}") + delay(100) + } + println("⏱️ [Cycle End] ${LocalTime.now()}") } - reanalysisList.clear() - remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks }.distinctBy { it.code }) - } else { - println("미확인 데이터 ${remainingCandidates.size}") } + //장외 + now.isAfter(LocalTime.of(18, 0)) || now.isBefore(LocalTime.of(8, 50)) -> { + when { + (now.hour == 0 && now.minute == 0 && (isSystemReadyToday || isSystemCleanedUpToday)) -> { + waitTime = 10.0 + isSystemReadyToday = false + isSystemCleanedUpToday = false + } - // [프로세스 3] 종목별 순회 분석 - var totalCount = remainingCandidates.size - println("후보군 조건 충족 총 개수 : ${totalCount}") - val iterator = remainingCandidates.iterator() + (now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> { + waitTime = 3.0 + if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { + KisWebSocketManager.disconnect() + tryRefreshToken() + } + } - while (iterator.hasNext()) { - totalCount-- - val stock = iterator.next() - try { - processSingleStock(stock, myCash, tradeService, callback) - // 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거 - } catch (e: Exception) { - println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") - // 오류 시 리스트에 남겨둘지, 제거할지 결정 - // (심각한 에러면 remove하고 다음 루프에서 다시 받는게 안전) - } finally { - iterator.remove() + (now.isAfter(LocalTime.of(18, 0))) -> { + try { + waitTime = 5.0 + println("current SystemCleanedUpToday is $isSystemCleanedUpToday") + if (!isSystemCleanedUpToday) { + println("🌙 [System] 업무 종료 및 자원 정리 시작...") + SystemSleepPreventer.sleepDisplay() // 모니터 끄기 + KisWebSocketManager.disconnect() + //isSystemReadyToday = false + if (LlamaServerManager.stopAll()) { + isSystemCleanedUpToday = true + } + } + println("✅ [System] 오늘의 모든 정리가 완료되었습니다.") + } catch (e: Exception) { + } + } + + (now.isAfter(LocalTime.of(18, 15)) && now.minute % 15 == 0) -> { + try { + waitTime = 5.0 + SystemSleepPreventer.sleepDisplay() // 모니터 끄기 + } catch (e: Exception) { + } + } + else -> { + waitTime = 5.0 + } } - println("남은 후보군 개수 : ${totalCount}") - delay(100) } - println("⏱️ [Cycle End] ${LocalTime.now()}") + else ->{ + waitTime = 3.0 + } } } catch (e: TimeoutCancellationException) { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") @@ -200,8 +316,7 @@ object AutoTradingManager { println("⚠️ [Loop Error] ${e.message}") delay(1500) } - - waitForNextCycle(0.2) + waitForNextCycle(waitTime) } } } @@ -302,12 +417,12 @@ object AutoTradingManager { } private suspend fun waitForNextCycle(minutes: Double) { - println("💤 대기 모드 진입...") + println("💤 대기 모드 진입... $minutes") val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...") - delay(1000) + delay(if(minutes > 3.0 ) 10000 else 1000) } } diff --git a/src/main/kotlin/service/LlamaServerManager.kt b/src/main/kotlin/service/LlamaServerManager.kt index 4d5dab6..de2cbeb 100644 --- a/src/main/kotlin/service/LlamaServerManager.kt +++ b/src/main/kotlin/service/LlamaServerManager.kt @@ -87,11 +87,37 @@ object LlamaServerManager { } } - fun stopAll() { + fun stopAll(): Boolean { + var allStopped = true // 모든 프로세스 종료 여부를 추적하는 플래그 + processes.forEach { (port, process) -> - process.destroy() - println("🛑 AI 서버 종료 (Port: $port)") + try { + process.destroy() // 1차: 부드러운 종료 시도 + + // 2차: 최대 3초 대기 후 종료되지 않으면 강제 종료 + if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly() // 강제 사살 + + // 강제 종료 후에도 프로세스가 살아있는지 최종 확인 + if (process.isAlive) { + println("❌ [Server $port] 강제 종료 명령 후에도 프로세스가 살아있습니다.") + allStopped = false + } else { + println("⚠️ [Server $port] 응답이 없어 강제 종료되었습니다.") + } + } else { + println("🛑 [Server $port] 정상 종료되었습니다.") + } + } catch (e: Exception) { + println("❌ [Server $port] 종료 중 오류: ${e.message}") + allStopped = false + } } - processes.clear() + + if (allStopped) { + processes.clear() // 모든 프로세스가 성공적으로 종료되거나 리스트에서 제거될 준비가 된 경우 + } + + return allStopped } } \ No newline at end of file diff --git a/src/main/kotlin/service/SystemSleepPreventer.kt b/src/main/kotlin/service/SystemSleepPreventer.kt index b1ed6dc..8fe960f 100644 --- a/src/main/kotlin/service/SystemSleepPreventer.kt +++ b/src/main/kotlin/service/SystemSleepPreventer.kt @@ -7,14 +7,61 @@ import ch.qos.logback.classic.Logger object SystemSleepPreventer { private var process: Process? = null + fun checkAndRequestAccessibility() { + if (!hasAccessibilityPermission()) { + println("⚠️ [System] 접근성 권한이 없습니다. 설정창을 엽니다.") + openAccessibilitySettings() + } else { + println("✅ [System] 접근성 권한이 확인되었습니다.") + } + } + /** + * 실제로 가벼운 이벤트를 발생시켜 권한 유무를 확인하는 함수 + */ + private fun hasAccessibilityPermission(): Boolean { + return try { + // System Events에 이름을 묻는 아주 가벼운 명령을 실행합니다. + val process = Runtime.getRuntime().exec( + arrayOf("osascript", "-e", "tell application \"System Events\" to get name") + ) + + // 권한이 없으면 팝업이 뜨며 대기할 수 있으므로, 아주 짧은 타임아웃을 줍니다. + val exited = process.waitFor(1500, java.util.concurrent.TimeUnit.MILLISECONDS) + + if (!exited) { + // 타임아웃 발생 시, 시스템이 권한 승인 팝업을 띄우고 대기 중일 확률이 높습니다. + process.destroyForcibly() + return false + } + + // 에러 스트림을 읽어 권한 거부 관련 메시지가 있는지 확인합니다. + val errorStream = process.errorStream.bufferedReader().readText() + val isDenied = errorStream.contains("not allowed") || process.exitValue() != 0 + + !isDenied + } catch (e: Exception) { + println("⚠️ [Permission] 체크 중 오류 발생: ${e.message}") + false + } + } + + private fun openAccessibilitySettings() { + try { + Runtime.getRuntime().exec(arrayOf( + "open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + )) + } catch (e: Exception) { + e.printStackTrace() + } + } /** * 맥의 절전 모드 및 디스플레이 취침을 방지하는 명령 실행 */ fun start() { val root = LoggerFactory.getLogger("Exposed") as Logger root.level = Level.ERROR - + checkAndRequestAccessibility() if (process?.isAlive == true) return try { @@ -27,6 +74,36 @@ object SystemSleepPreventer { } } + /** + * 모니터를 즉시 잠자기 모드로 전환 + */ + fun sleepDisplay() { + try { + // pmset을 이용해 디스플레이를 즉시 끔 + Runtime.getRuntime().exec("pmset displaysleepnow") + println("🌙 [System] 오후 6시 30분: 모니터를 잠자기 모드로 전환합니다.") + } catch (e: Exception) { + println("⚠️ 모니터 잠자기 실패: ${e.message}") + } + } + + /** + * 마우스 움직임을 시뮬레이션하거나 디스플레이 깨우기 명령 실행 + */ + fun wakeDisplay() { + try { + // caffeinate를 다시 실행하여 깨우거나, + // 쉘 명령어로 키 입력을 시뮬레이션하여 화면을 깨움 + Runtime.getRuntime().exec( + arrayOf("caffeinate", "-u", "-t", "3600") + ) +// Runtime.getRuntime().exec(arrayOf("osascript", "-e", "tell application \"System Events\" to key code 123")) + println("☀️ 오전 8시: 모니터를 깨웁니다.") + } catch (e: Exception) { + println("⚠️ 모니터 깨우기 실패: ${e.message}") + } + } + /** * 앱 종료 시 프로세스 함께 종료 */ diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index 588073c..f016784 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -40,7 +40,7 @@ import kotlin.collections.mutableListOf @Composable fun DashboardScreen() { val tradeService = remember { KisTradeService } - val wsManager = remember { KisWebSocketManager() } + val wsManager = remember { KisWebSocketManager } val scope = rememberCoroutineScope() var selectedStockCode by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") } diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index 6a24d52..8445e46 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -122,7 +122,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { // 1. KisSession.config 업데이트 및 DB 저장 KisSession.config = config DatabaseFactory.saveConfig(config) - val authService = KisAuthService() + val authService = KisAuthService val tradeService = KisTradeService val authSuccess = authService.refreshAllTokens() val wsKeySuccess = tradeService.refreshWebsocketKey()