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 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)

View File

@ -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 {

View File

@ -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("🛑 웹소켓 완전 종료")
}
}

View File

@ -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<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
// 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<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] 종목별 순회 분석
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)
}
}

View File

@ -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
}
}

View File

@ -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}")
}
}
/**
* 종료 프로세스 함께 종료
*/

View File

@ -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("") }

View File

@ -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()