diff --git a/build.gradle.kts b/build.gradle.kts index f87ebb8..77818fb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,11 +68,19 @@ compose.desktop { isEnabled.set(false) // 임시로 false 설정 } nativeDistributions { - targetFormats(TargetFormat.Dmg) + targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Msi) packageName = "AutoTradeAI" + macOS { bundleID = "com.autotrade.ai" } + + // 윈도우 관련 상세 설정 (선택 사항) + windows { + packageVersion = "1.0.0" + shortcut = true // 바탕화면 바로가기 생성 + menu = true // 시작 메뉴 등록 + } } } } diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index b8c4568..0331d0f 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -14,9 +14,12 @@ 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 @@ -75,14 +78,47 @@ fun getLlamaBinPath(): String { } } fun main() = application { - SystemSleepPreventer.start() + + val trayState = rememberTrayState() + var isWindowOpen by remember { mutableStateOf(true) } // 창의 표시 상태 관리 + + + 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() + }) + } + ) // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) val binPath = getLlamaBinPath() val windowState = rememberWindowState( placement = WindowPlacement.Floating ) - Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) { + if (isWindowOpen) { + + Window(onCloseRequest = { isWindowOpen = false }, title = "KIS AI 자동매매", state = windowState) { var currentScreen by remember { mutableStateOf(AppScreen.Settings) } var isLoaded by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -142,6 +178,7 @@ fun main() = application { AutoTradingManager.onMarketClosed = { println("프로그램 초기화 실행됨") currentScreen = AppScreen.Settings + isWindowOpen = false } SettingsScreen( onAuthSuccess = { @@ -178,4 +215,5 @@ fun main() = application { } } } + } } \ No newline at end of file diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 952cb7e..e6ab279 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -90,6 +90,12 @@ object TradeLogTable : Table("trade_logs") { override val primaryKey = PrimaryKey(id) } +object HolidayTable : Table("holiday_cache") { + val bassDt = varchar("bass_dt", 8) // YYYYMMDD + val isHoliday = bool("is_holiday") + override val primaryKey = PrimaryKey(bassDt) +} + object DatabaseFactory { fun init() { val dbPath = File("db/autotrade_db").absolutePath @@ -101,10 +107,23 @@ object DatabaseFactory { transaction { // 테이블 생성 (AutoTradeTable 포함) - SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable) + SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,HolidayTable) } } + fun saveHoliday(date: String, holiday: Boolean) = transaction { + HolidayTable.replace { + it[bassDt] = date + it[isHoliday] = holiday + } + } + + // 특정 날짜의 휴장 여부 조회 + fun getHoliday(date: String): Boolean? = transaction { + HolidayTable.select { HolidayTable.bassDt eq date } + .map { it[HolidayTable.isHoliday] } + .singleOrNull() + } /** * 새로운 자동매매 건 등록 (주로 PENDING_BUY 상태로 시작) diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 786be52..dcfff27 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -52,6 +52,29 @@ object KisTradeService { private val prodUrl = "https://openapi.koreainvestment.com:9443" private val vtsUrl = "https://openapivts.koreainvestment.com:29443" + suspend fun fetchIsHoliday(date: String): Result { + val config = KisSession.config + return try { + val response = client.get("$prodUrl/uapi/domestic-stock/v1/quotations/chk-holiday") { + header("authorization", "Bearer ${config.marketToken}") + header("appkey", config.realAppKey) + header("appsecret", config.realSecretKey) + header("tr_id", "CTCA0903R") + header("custtype", "P") + + parameter("BASS_DT", date) + parameter("CTX_AREA_NK", "") + parameter("CTX_AREA_FK", "") + } + val body = response.body() + // output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일 + val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y" + Result.success(!isOpeningDay) + } catch (e: Exception) { + Result.failure(e) + } + } + /** * [1] 통합 잔고 조회 (국내 + 해외 합산) */ diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index ccff1f7..c873b52 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -4,6 +4,9 @@ import AutoTradeItem import network.TradingDecision import TradingLogStore import TradingLogStore.decisionLogs +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import getLlamaBinPath import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -61,6 +64,31 @@ object AutoTradingManager { // private val processedCodes = mutableSetOf() // 중복 처리 방지용 (선택 사항) private val reanalysisList = mutableListOf() private val retryCountMap = mutableMapOf() + var shouldShowFullWindow by mutableStateOf(false) + fun startBackgroundScheduler() { +// scope.launch { +// while (isActive) { +// val now = LocalTime.now(ZoneId.of("Asia/Seoul")) +// +// // 1. 오전 8시 30분 ~ 15시 30분 사이인지 확인 +// if (now.isAfter(LocalTime.of(8, 30)) && now.isBefore(LocalTime.of(15, 30))) { +// // 2. 아직 오늘 시스템 준비가 안 되었고, 설정값이 있는 경우 +// if (!isSystemReadyToday && KisSession.config.realAppKey.isNotEmpty()) { +// println("⏰ [Scheduler] 자동 실행 시간이 되어 인증을 시작합니다.") +// SystemSleepPreventer.wakeDisplay() // +// +// // 인증 및 토큰 갱신 시도 +// val success = KisAuthService.refreshAllTokens() && KisTradeService.refreshWebsocketKey() +// if (success) { +// isSystemReadyToday = true +// startAutoDiscoveryLoop() // 자동 매매 루프 시작 +// } +// } +// } +// delay(60_000) // 1분마다 체크 +// } +// } + } val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> if (isSuccess && completeTradingDecision != null) { @@ -389,6 +417,7 @@ object AutoTradingManager { } KisWebSocketManager.connect() isSystemReadyToday = true + shouldShowFullWindow = true } else { println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") } @@ -425,6 +454,16 @@ object AutoTradingManager { // Main.kt에 설정 화면으로 가라고 신호 전송 stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) return@launch + } else if (now.isAfter(H08M30) && now.isBefore(H08M50) && !isSystemReadyToday) { + if (MarketUtil.canTradeToday()) { + println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") + tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true) + } else { + println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.") + isSystemReadyToday = false + delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여 + continue + } } when { diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt index 7d0964e..92f9676 100644 --- a/src/main/kotlin/ui/TradingDecisionLog.kt +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -23,15 +25,62 @@ import androidx.compose.ui.unit.sp import model.ConfigIndex import model.KisSession +@OptIn(ExperimentalMaterialApi::class) @Composable fun TradingDecisionLog() { + var searchQuery by remember { mutableStateOf("") } + var selectedFilter by remember { mutableStateOf("전체") } + val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING") + + // [핵심] 원본 로그에서 필터 조건에 맞는 리스트만 산출 + val filteredLogs = TradingLogStore.decisionLogs.filter { log -> + val matchesType = if (selectedFilter == "전체") true else log.decision == selectedFilter + val matchesQuery = log.stockName.contains(searchQuery, ignoreCase = true) || + log.reason.contains(searchQuery, ignoreCase = true) + matchesType && matchesQuery + } + Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) { Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) - Divider(Modifier.padding(vertical = 8.dp)) - LazyColumn(reverseLayout = true) { // 최신 로그가 위로 오게 함 - items(TradingLogStore.decisionLogs) { log -> + // [추가] 상단 검색 및 필터 UI + Column(modifier = Modifier.padding(vertical = 8.dp)) { + // 1. 검색창 + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text("종목명 또는 내용 검색") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 2. 필터 버튼 그룹 (Chip 형태) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + filterOptions.forEach { option -> + val isSelected = selectedFilter == option + FilterChip( + selected = isSelected, + onClick = { selectedFilter = option }, + colors = ChipDefaults.filterChipColors( + selectedBackgroundColor = Color(0xFF0E62CF), + selectedContentColor = Color.White + ) + ) { + Text(option, fontSize = 11.sp) + } + } + } + } + + Divider(Modifier.padding(bottom = 8.dp)) + + // [수정] filteredLogs를 사용하여 최신 로그가 위로 오게 표시 + LazyColumn(reverseLayout = true) { + items(filteredLogs) { log -> Card( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 2.dp @@ -43,10 +92,10 @@ fun TradingDecisionLog() { text = log.decision, color = when (log.decision) { "BUY" -> Color.Red - "SETTING" -> Color(0xFFFFA500) // 주황색 - "SELL" -> Color(0xFF800080) // 보라색 - "HOLD" -> Color.Gray // HOLD는 그레이 - else -> Color.Gray // 그 외 기본값 그레이 + "SETTING" -> Color(0xFFFFA500) + "SELL" -> Color(0xFF800080) + "HOLD" -> Color.Gray + else -> Color.Gray }, fontWeight = FontWeight.ExtraBold ) diff --git a/src/main/kotlin/util/MarketUtil.kt b/src/main/kotlin/util/MarketUtil.kt index 6e89c2f..e83a94d 100644 --- a/src/main/kotlin/util/MarketUtil.kt +++ b/src/main/kotlin/util/MarketUtil.kt @@ -1,9 +1,42 @@ package util +import network.KisTradeService import java.time.LocalTime import java.time.ZoneId object MarketUtil { + private var isHolidayCached: Boolean? = null // 하루 한 번만 체크하기 위한 캐시 + + suspend fun canTradeToday(): Boolean { + val seoulZone = java.time.ZoneId.of("Asia/Seoul") + val now = java.time.ZonedDateTime.now(seoulZone) + val todayStr = now.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")) + + // 1. 주말 체크 + val dayOfWeek = now.dayOfWeek.value + if (dayOfWeek >= 6) return false + // 1. 주말 체크 (토, 일) + val cachedHoliday = DatabaseFactory.getHoliday(todayStr) + if (cachedHoliday != null) { + println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(cachedHoliday) "휴장" else "영업일"}") + return !cachedHoliday + } + + // 3. DB에 없으면 API 호출 + return try { + val result = KisTradeService.fetchIsHoliday(todayStr) + val isHoliday = result.getOrDefault(true) + + // 결과를 DB에 저장하여 다음 실행 시 재사용 + DatabaseFactory.saveHoliday(todayStr, isHoliday) + + println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다.") + !isHoliday + } catch (e: Exception) { + false + } + } + fun isKoreanMarketOpen(): Boolean { // 한국 시간대 기준 현재 시간 가져오기 val now = LocalTime.now(ZoneId.of("Asia/Seoul")) diff --git a/src/main/resources/neko.png b/src/main/resources/neko.png new file mode 100644 index 0000000..03970c1 Binary files /dev/null and b/src/main/resources/neko.png differ