This commit is contained in:
lunaticbum 2026-03-13 10:41:10 +09:00
parent eb2dc0efd0
commit 6d340b7dc0
8 changed files with 307 additions and 77 deletions

View File

@ -41,6 +41,7 @@ import service.LlamaServerManager
import network.NewsService import network.NewsService
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import service.AutoTradingManager import service.AutoTradingManager
import service.AutoTradingManager.isSystemCleanedUpToday
import service.SystemSleepPreventer import service.SystemSleepPreventer
import service.TradingDecisionCallback import service.TradingDecisionCallback
import ui.DashboardScreen import ui.DashboardScreen
@ -151,7 +152,8 @@ fun main() = application {
onAuthSuccess = { onAuthSuccess = {
// 2. 설정 및 인증 완료 시점의 처리 // 2. 설정 및 인증 완료 시점의 처리
val config = KisSession.config val config = KisSession.config
AutoTradingManager.isSystemReadyToday = true
AutoTradingManager.isSystemCleanedUpToday = false
// LLM 서버 시작 (설정된 모델 경로 사용) // LLM 서버 시작 (설정된 모델 경로 사용)
if (config.modelPath.isNotEmpty()) { if (config.modelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.modelPath,port = 8080) LlamaServerManager.startServer(binPath, config.modelPath,port = 8080)

View File

@ -19,7 +19,7 @@ import model.TokenRequest
import model.TokenResponse import model.TokenResponse
import java.time.LocalDateTime import java.time.LocalDateTime
class KisAuthService { object KisAuthService {
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {

View File

@ -14,7 +14,7 @@ import model.RealTimeTrade
import util.AesCrypto import util.AesCrypto
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class KisWebSocketManager { object KisWebSocketManager {
private val client = HttpClient { private val client = HttpClient {
install(WebSockets) { install(WebSockets) {
pingInterval = 20_000 // 20초마다 표준 웹소켓 핑 전송 (서버-클라이언트 연결 유지 도움) pingInterval = 20_000 // 20초마다 표준 웹소켓 핑 전송 (서버-클라이언트 연결 유지 도움)
@ -23,7 +23,7 @@ class KisWebSocketManager {
private var session: DefaultClientWebSocketSession? = null private var session: DefaultClientWebSocketSession? = null
private val isConnected = AtomicBoolean(false) private val isConnected = AtomicBoolean(false)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var connectJob: Job? = null
// 콜백 리스너 // 콜백 리스너
var onPriceUpdate: ((RealTimeTrade) -> Unit)? = null var onPriceUpdate: ((RealTimeTrade) -> Unit)? = null
@ -32,8 +32,8 @@ class KisWebSocketManager {
suspend fun connect() { suspend fun connect() {
if (isConnected.get()) return if (isConnected.get()) return
val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:31000" else "ops.koreainvestment.com:21000" val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:31000" else "ops.koreainvestment.com:21000"
connectJob?.cancelAndJoin()
scope.launch { connectJob = scope.launch {
while (isActive) { // 재연결을 위한 루프 추가 while (isActive) { // 재연결을 위한 루프 추가
try { try {
client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") { 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) activeSubscriptions.addAll(requiredCodes)
} }
suspend fun disconnect() {
println("🔌 웹소켓 연결 종료 중...")
isConnected.set(false)
connectJob?.cancelAndJoin() // 루프 자체를 중단
session?.close()
session = null
connectJob = null
println("🛑 웹소켓 완전 종료")
}
} }

View File

@ -1,6 +1,7 @@
package service package service
import TradingDecision import TradingDecision
import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -23,7 +24,9 @@ import model.UnifiedBalance
import network.DartCodeManager import network.DartCodeManager
import network.FinancialMapper import network.FinancialMapper
import network.FinancialStatement import network.FinancialStatement
import network.KisAuthService
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil import util.MarketUtil
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
@ -116,83 +119,196 @@ object AutoTradingManager {
delay(200) // API 호출 부하 방지 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) { private fun runDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
discoveryJob = scope.launch { discoveryJob = scope.launch {
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
while (isActive) { while (isActive) {
try { try {
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고 lastTickTime.set(System.currentTimeMillis()) // 생존 신고
// if (now.minute % 5 == 0) {
withTimeout(CYCLE_TIMEOUT) { // SystemSleepPreventer.sleepDisplay()
println("⏱️ [Cycle Start] ${LocalTime.now()}") // } else {
// SystemSleepPreventer.wakeDisplay()
// [프로세스 1] 장 마감 및 잔고 체크 // }
val now = LocalTime.now(ZoneId.of("Asia/Seoul")) when {
//&& now.isBefore(LocalTime.of(15, 30)) //장중
if (now.isAfter(LocalTime.of(15, 20)) ) { now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> {
executeClosingLiquidation(tradeService) waitTime = 0.2
return@withTimeout if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
} // 토큰 중 하나라도 만료 5분 전이거나 비어있다면 다시 준비 상태로 전환
// addToReanalysis(RankingStock(mksc_shrn_iscd = ,hts_kor_isnm = )) if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
val balance = tradeService.fetchIntegratedBalance().getOrNull() if (isSystemReadyToday) {
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
balance?.let { resumePendingSellOrders(tradeService,it) } isSystemReadyToday = false
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L KisWebSocketManager.disconnect()
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() tryRefreshToken()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } }
// [프로세스 2] 후보군 수집
if (remainingCandidates.isEmpty()) {
val candidates: MutableList<RankingStock> = 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
} }
} }
.filter { !it.name.contains("호스팩", true) } withTimeout(CYCLE_TIMEOUT) {
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) } println("⏱️ [Cycle Start] ${LocalTime.now()}")
.toMutableList()
if (reanalysisList.isNotEmpty()) { val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
candidates.addAll(reanalysisList.asReversed()) 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<RankingStock> = 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] 종목별 순회 분석 (now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> {
var totalCount = remainingCandidates.size waitTime = 3.0
println("후보군 조건 충족 총 개수 : ${totalCount}") if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
val iterator = remainingCandidates.iterator() KisWebSocketManager.disconnect()
tryRefreshToken()
}
}
while (iterator.hasNext()) { (now.isAfter(LocalTime.of(18, 0))) -> {
totalCount-- try {
val stock = iterator.next() waitTime = 5.0
try { println("current SystemCleanedUpToday is $isSystemCleanedUpToday")
processSingleStock(stock, myCash, tradeService, callback) if (!isSystemCleanedUpToday) {
// 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거 println("🌙 [System] 업무 종료 및 자원 정리 시작...")
} catch (e: Exception) { SystemSleepPreventer.sleepDisplay() // 모니터 끄기
println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") KisWebSocketManager.disconnect()
// 오류 시 리스트에 남겨둘지, 제거할지 결정 //isSystemReadyToday = false
// (심각한 에러면 remove하고 다음 루프에서 다시 받는게 안전) if (LlamaServerManager.stopAll()) {
} finally { isSystemCleanedUpToday = true
iterator.remove() }
}
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) { } catch (e: TimeoutCancellationException) {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
@ -200,8 +316,7 @@ object AutoTradingManager {
println("⚠️ [Loop Error] ${e.message}") println("⚠️ [Loop Error] ${e.message}")
delay(1500) delay(1500)
} }
waitForNextCycle(waitTime)
waitForNextCycle(0.2)
} }
} }
} }
@ -302,12 +417,12 @@ object AutoTradingManager {
} }
private suspend fun waitForNextCycle(minutes: Double) { private suspend fun waitForNextCycle(minutes: Double) {
println("💤 대기 모드 진입...") println("💤 대기 모드 진입... $minutes")
val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L)
while (System.currentTimeMillis() < endWait && isRunning()) { while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
println("💤 대기 모드 상태 확인...") println("💤 대기 모드 상태 확인...")
delay(1000) delay(if(minutes > 3.0 ) 10000 else 1000)
} }
} }

View File

@ -87,11 +87,37 @@ object LlamaServerManager {
} }
} }
fun stopAll() { fun stopAll(): Boolean {
var allStopped = true // 모든 프로세스 종료 여부를 추적하는 플래그
processes.forEach { (port, process) -> processes.forEach { (port, process) ->
process.destroy() try {
println("🛑 AI 서버 종료 (Port: $port)") 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
} }
} }

View File

@ -7,14 +7,61 @@ import ch.qos.logback.classic.Logger
object SystemSleepPreventer { object SystemSleepPreventer {
private var process: Process? = null 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() { fun start() {
val root = LoggerFactory.getLogger("Exposed") as Logger val root = LoggerFactory.getLogger("Exposed") as Logger
root.level = Level.ERROR root.level = Level.ERROR
checkAndRequestAccessibility()
if (process?.isAlive == true) return if (process?.isAlive == true) return
try { 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}")
}
}
/** /**
* 종료 프로세스 함께 종료 * 종료 프로세스 함께 종료
*/ */

View File

@ -40,7 +40,7 @@ import kotlin.collections.mutableListOf
@Composable @Composable
fun DashboardScreen() { fun DashboardScreen() {
val tradeService = remember { KisTradeService } val tradeService = remember { KisTradeService }
val wsManager = remember { KisWebSocketManager() } val wsManager = remember { KisWebSocketManager }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var selectedStockCode by remember { mutableStateOf("") } var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") }

View File

@ -122,7 +122,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
// 1. KisSession.config 업데이트 및 DB 저장 // 1. KisSession.config 업데이트 및 DB 저장
KisSession.config = config KisSession.config = config
DatabaseFactory.saveConfig(config) DatabaseFactory.saveConfig(config)
val authService = KisAuthService() val authService = KisAuthService
val tradeService = KisTradeService val tradeService = KisTradeService
val authSuccess = authService.refreshAllTokens() val authSuccess = authService.refreshAllTokens()
val wsKeySuccess = tradeService.refreshWebsocketKey() val wsKeySuccess = tradeService.refreshWebsocketKey()