atrade/src/main/kotlin/Main.kt
2026-04-09 14:01:43 +09:00

336 lines
14 KiB
Kotlin

import ConfigTable.grade_1_allocationrate
import ConfigTable.grade_1_buy
import ConfigTable.grade_1_profit
import ConfigTable.grade_2_allocationrate
import ConfigTable.grade_2_buy
import ConfigTable.grade_2_profit
import ConfigTable.grade_3_allocationrate
import ConfigTable.grade_3_buy
import ConfigTable.grade_3_profit
import ConfigTable.grade_4_allocationrate
import ConfigTable.grade_4_buy
import ConfigTable.grade_4_profit
import ConfigTable.grade_5_allocationrate
import ConfigTable.grade_5_buy
import ConfigTable.grade_5_profit
import ConfigTable.loss_max_money
import ConfigTable.loss_maxrate
import ConfigTable.loss_minrate
import ConfigTable.max_count
import ConfigTable.max_holding_count
import ConfigTable.stop_Loss
import ConfigTable.take_profit
import Defines.DETAILLOG
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import model.AppConfig
import model.KisSession
import network.DartCodeManager
import network.KisTradeService
import network.KisWebSocketManager
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.SettingsScreen
import ui.TradingDecisionLog
import util.PortFinder
import java.io.File
// 화면 상태 정의
enum class AppScreen { Settings, Dashboard, TradingDecision }
fun getLlamaBinPath(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
val basePath = "./src/main/resources/bin"
return when {
// Apple Silicon (M1/M2/M3)
os.contains("mac") && (arch.contains("aarch64") || arch.contains("arm64")) -> {
"$basePath/mac-arm64/llama-server"
}
// Intel Mac (2017)
os.contains("mac") -> {
"$basePath/mac-x64/llama-server"
}
// Windows NUC
os.contains("win") -> {
"$basePath/win-x64-n/llama-server.exe"
}
else -> "$basePath/llama-server"
}
}
fun initLogger(isDebug: Boolean) {
val root = org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME) as ch.qos.logback.classic.Logger
if (isDebug) {
root.level = ch.qos.logback.classic.Level.DEBUG
println("🛠️ 디버그 모드: 상세 로그를 출력합니다.")
} else {
root.level = ch.qos.logback.classic.Level.ERROR
// 특정 라이브러리(Exposed)만 콕 집어서 끄기
(org.slf4j.LoggerFactory.getLogger("Exposed") as ch.qos.logback.classic.Logger).level = ch.qos.logback.classic.Level.OFF
println("🤫 운영 모드: 에러 로그만 출력합니다.")
}
}
private var isAppStarted = false
fun main() = application {
if (!isAppStarted) {
initLogger(DETAILLOG)
try {
val (port1, port2) = PortFinder.findAvailablePortPair(18080, false)
if (port1 > 18000 && port2 > port1) {
println("🚀 AI 서버용 포트 할당 완료: 메인($port1), 서브($port2)")
LLM_PORT = port1
EMBEDDING_PORT = port2
isAppStarted = true
} else {
println("🚀 AI 서버용 포트 할당 실패")
exitApplication()
}
} catch (e: Exception) {
println("🚀 AI 서버용 포트 할당 에러 : ${e.message}")
e.printStackTrace()
exitApplication()
}
}
val trayState = rememberTrayState()
var isWindowOpen by remember { mutableStateOf(false) } // 창의 표시 상태 관리
LaunchedEffect(Unit) {
SystemSleepPreventer.start()
AutoTradingManager.startBackgroundScheduler()
}
LaunchedEffect(AutoTradingManager.shouldShowFullWindow) {
if (AutoTradingManager.shouldShowFullWindow) {
isWindowOpen = true
// 신호를 처리했으므로 다시 초기화 (트레이에서 수동으로 닫았을 때 다시 뜰 수 있게 함)
AutoTradingManager.shouldShowFullWindow = false
}
}
// 트레이 아이콘 설정
Tray(
state = trayState,
icon = painterResource("neko.png"), // resources 폴더에 아이콘 파일 필요
tooltip = "KIS AI 자동매매",
onAction = { isWindowOpen = true }, // 트레이 아이콘 더블클릭 시 창 열기
menu = {
Item("앱 열기", onClick = { isWindowOpen = true })
Separator()
Item("종료", onClick = {
// 종료 전 리소스 정리 호출
AutoTradingManager.stopDiscovery()
exitApplication()
})
}
)
// ProcessBuilder 실행 보조 함수 (로그 출력용)
fun runCommand(vararg command: String) {
try {
val process = ProcessBuilder(*command)
.redirectErrorStream(true)
.start()
// 명령어 실행 결과 출력 (에러 디버깅용)
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode != 0) {
println("⚠️ [명령어 경고] ${command.joinToString(" ")} -> 반환 코드: $exitCode, 메시지: $output")
}
} catch (e: Exception) {
println("❌ [명령어 에러] ${command.joinToString(" ")} 실행 중 에러: ${e.message}")
}
}
/**
* Mac OS 전용: '확인되지 않은 개발자' 보안(Gatekeeper) 해제 및 실행 권한 부여
* @param targetPath 권한을 풀 파일 또는 폴더의 절대 경로
*/
fun unlockMacPermissions(targetPath: String) {
val os = System.getProperty("os.name").lowercase()
if (!os.contains("mac")) return // Mac 환경이 아니면 조용히 패스
val targetFile = File(targetPath).parentFile
if (!targetFile.exists()) {
println("⚠️ [System] 권한을 부여할 경로를 찾을 수 없습니다: $targetPath")
return
}
try {
println("🔓 [System] Mac 보안 권한 영구 해제 시도 중... ($targetPath)")
// 1단계: Apple Quarantine(격리) 속성 제거 (웹에서 다운받았다는 꼬리표 떼기)
runCommand("xattr", "-cr", targetPath)
// 2단계: 파일 읽기/쓰기/실행 권한 부여
runCommand("chmod", "-R", "777", targetPath)
// 💡 3단계 [핵심]: 임시 서명 (Ad-hoc Signing) 강제 부여
// Apple Silicon (M1/M2/M3)은 서명 없는 바이너리를 실행 차단하므로, 임시 서명을 박아버립니다.
runCommand("codesign", "--force", "--deep", "--sign", "-", targetPath)
// 💡 4단계 [핵심]: Gatekeeper (spctl) 화이트리스트에 명시적으로 추가
// "확인되지 않은 개발자" 경고창을 영구적으로 우회합니다.
runCommand("spctl", "--add", targetPath)
println("✅ [System] Mac 강력 권한 해제 및 임시 서명 완료! 이제 경고창이 뜨지 않습니다.")
} catch (e: Exception) {
println("❌ [System] Mac 권한 부여 실패: ${e.message}")
}
}
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
val binPath = getLlamaBinPath()
unlockMacPermissions(binPath)
val windowState = rememberWindowState(
placement = WindowPlacement.Floating
)
if (isWindowOpen) {
Window(onCloseRequest = { isWindowOpen = false }, title = "KIS AI 자동매매", state = windowState) {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
var isLoaded by remember { mutableStateOf(false) }
// 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입)
LaunchedEffect(Unit) {
DatabaseFactory.init()
transaction {
ConfigTable.selectAll().lastOrNull()?.let {
KisSession.config = AppConfig(
realAppKey = it[ConfigTable.realAppKey],
realSecretKey = it[ConfigTable.realSecretKey],
realAccountNo = it[ConfigTable.realAccountNo],
vtsAppKey = it[ConfigTable.vtsAppKey],
vtsSecretKey = it[ConfigTable.vtsSecretKey],
vtsAccountNo = it[ConfigTable.vtsAccountNo],
nAppKey = it[ConfigTable.nAppKey],
nSecretKey = it[ConfigTable.nSecretKey],
dAppKey = it[ConfigTable.dAppKey],
isSimulation = it[ConfigTable.isSimulation],
htsId = it[ConfigTable.htsId],
modelPath = it[ConfigTable.modelPath],
embedModelPath = it[ConfigTable.embedModelPath],
FEES_AND_TAXRATE = it[ConfigTable.fees_and_taxrate],
MINIMUM_NET_PROFIT = it[ConfigTable.minimum_net_profit],
BUY_WEIGHT = it[ConfigTable.buy_weight],
MAX_BUDGET = it[ConfigTable.max_budget],
MAX_PRICE = it[ConfigTable.max_price],
MIN_PRICE = it[ConfigTable.min_price],
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
SELL_PROFIT = it[ConfigTable.sell_profit],
GRADE_5_BUY = it[grade_5_buy],
GRADE_5_PROFIT = it[grade_5_profit],
GRADE_4_BUY = it[grade_4_buy],
GRADE_4_PROFIT = it[grade_4_profit],
GRADE_3_BUY = it[grade_3_buy],
GRADE_3_PROFIT = it[grade_3_profit],
GRADE_2_BUY = it[grade_2_buy],
GRADE_2_PROFIT = it[grade_2_profit],
GRADE_1_BUY = it[grade_1_buy],
GRADE_1_PROFIT = it[grade_1_profit],
GRADE_1_ALLOCATIONRATE = it[grade_1_allocationrate],
GRADE_2_ALLOCATIONRATE = it[grade_2_allocationrate],
GRADE_3_ALLOCATIONRATE = it[grade_3_allocationrate],
GRADE_4_ALLOCATIONRATE = it[grade_4_allocationrate],
GRADE_5_ALLOCATIONRATE = it[grade_5_allocationrate],
stop_Loss = it[stop_Loss],
take_profit = it[take_profit],
loss_max = it[loss_maxrate],
loss_min = it[loss_minrate],
loss_money = it[loss_max_money],
MAX_COUNT = it[max_count],
max_holding_count = it[max_holding_count],
)
}
}
isLoaded = true
}
if (!isLoaded) {
// 로딩 중 표시
CircularProgressIndicator()
} else {
when (currentScreen) {
AppScreen.Settings -> {
AutoTradingManager.onMarketClosed = {
println("프로그램 초기화 실행됨")
currentScreen = AppScreen.Settings
isWindowOpen = false
}
SettingsScreen(
onAuthSuccess = {
// 2. 설정 및 인증 완료 시점의 처리
val config = KisSession.config
AutoTradingManager.isSystemReadyToday = true
AutoTradingManager.isSystemCleanedUpToday = false
CoroutineScope(Dispatchers.Default).launch {
AutoTradingManager.startAutoDiscoveryLoop()
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
KisWebSocketManager.connect()
}
if (config.modelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.modelPath,port = LLM_PORT)
}
if (config.embedModelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT)
}
// 대시보드로 화면 전환
currentScreen = AppScreen.TradingDecision
}
)
}
AppScreen.Dashboard -> {
// DashboardScreen()
}
AppScreen.TradingDecision -> {
TradingDecisionLog()
}
}
}
}
}
}