This commit is contained in:
lunaticbum 2026-03-20 17:55:27 +09:00
parent 7ce24cc631
commit d804c7061b
8 changed files with 220 additions and 11 deletions

View File

@ -68,11 +68,19 @@ compose.desktop {
isEnabled.set(false) // 임시로 false 설정 isEnabled.set(false) // 임시로 false 설정
} }
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg) targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Msi)
packageName = "AutoTradeAI" packageName = "AutoTradeAI"
macOS { macOS {
bundleID = "com.autotrade.ai" bundleID = "com.autotrade.ai"
} }
// 윈도우 관련 상세 설정 (선택 사항)
windows {
packageVersion = "1.0.0"
shortcut = true // 바탕화면 바로가기 생성
menu = true // 시작 메뉴 등록
}
} }
} }
} }

View File

@ -14,9 +14,12 @@ import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Window
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
@ -75,14 +78,47 @@ fun getLlamaBinPath(): String {
} }
} }
fun main() = application { fun main() = application {
val trayState = rememberTrayState()
var isWindowOpen by remember { mutableStateOf(true) } // 창의 표시 상태 관리
LaunchedEffect(Unit) {
SystemSleepPreventer.start() 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 binPath = getLlamaBinPath()
val windowState = rememberWindowState( val windowState = rememberWindowState(
placement = WindowPlacement.Floating 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 currentScreen by remember { mutableStateOf(AppScreen.Settings) }
var isLoaded by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -142,6 +178,7 @@ fun main() = application {
AutoTradingManager.onMarketClosed = { AutoTradingManager.onMarketClosed = {
println("프로그램 초기화 실행됨") println("프로그램 초기화 실행됨")
currentScreen = AppScreen.Settings currentScreen = AppScreen.Settings
isWindowOpen = false
} }
SettingsScreen( SettingsScreen(
onAuthSuccess = { onAuthSuccess = {
@ -179,3 +216,4 @@ fun main() = application {
} }
} }
} }
}

View File

@ -90,6 +90,12 @@ object TradeLogTable : Table("trade_logs") {
override val primaryKey = PrimaryKey(id) 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 { object DatabaseFactory {
fun init() { fun init() {
val dbPath = File("db/autotrade_db").absolutePath val dbPath = File("db/autotrade_db").absolutePath
@ -101,10 +107,23 @@ object DatabaseFactory {
transaction { transaction {
// 테이블 생성 (AutoTradeTable 포함) // 테이블 생성 (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 상태로 시작) * 새로운 자동매매 등록 (주로 PENDING_BUY 상태로 시작)

View File

@ -52,6 +52,29 @@ object KisTradeService {
private val prodUrl = "https://openapi.koreainvestment.com:9443" private val prodUrl = "https://openapi.koreainvestment.com:9443"
private val vtsUrl = "https://openapivts.koreainvestment.com:29443" private val vtsUrl = "https://openapivts.koreainvestment.com:29443"
suspend fun fetchIsHoliday(date: String): Result<Boolean> {
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<JsonObject>()
// 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] 통합 잔고 조회 (국내 + 해외 합산) * [1] 통합 잔고 조회 (국내 + 해외 합산)
*/ */

View File

@ -4,6 +4,9 @@ import AutoTradeItem
import network.TradingDecision import network.TradingDecision
import TradingLogStore import TradingLogStore
import TradingLogStore.decisionLogs import TradingLogStore.decisionLogs
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import getLlamaBinPath import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -61,6 +64,31 @@ object AutoTradingManager {
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항) // private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>() private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>() private val retryCountMap = mutableMapOf<String, Int>()
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 -> val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) { if (isSuccess && completeTradingDecision != null) {
@ -389,6 +417,7 @@ object AutoTradingManager {
} }
KisWebSocketManager.connect() KisWebSocketManager.connect()
isSystemReadyToday = true isSystemReadyToday = true
shouldShowFullWindow = true
} else { } else {
println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.")
} }
@ -425,6 +454,16 @@ object AutoTradingManager {
// Main.kt에 설정 화면으로 가라고 신호 전송 // Main.kt에 설정 화면으로 가라고 신호 전송
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
return@launch 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 { when {

View File

@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@ -23,15 +25,62 @@ import androidx.compose.ui.unit.sp
import model.ConfigIndex import model.ConfigIndex
import model.KisSession import model.KisSession
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun TradingDecisionLog() { 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))) { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) { Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) {
Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6)
Divider(Modifier.padding(vertical = 8.dp))
LazyColumn(reverseLayout = true) { // 최신 로그가 위로 오게 함 // [추가] 상단 검색 및 필터 UI
items(TradingLogStore.decisionLogs) { log -> 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( Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
elevation = 2.dp elevation = 2.dp
@ -43,10 +92,10 @@ fun TradingDecisionLog() {
text = log.decision, text = log.decision,
color = when (log.decision) { color = when (log.decision) {
"BUY" -> Color.Red "BUY" -> Color.Red
"SETTING" -> Color(0xFFFFA500) // 주황색 "SETTING" -> Color(0xFFFFA500)
"SELL" -> Color(0xFF800080) // 보라색 "SELL" -> Color(0xFF800080)
"HOLD" -> Color.Gray // HOLD는 그레이 "HOLD" -> Color.Gray
else -> Color.Gray // 그 외 기본값 그레이 else -> Color.Gray
}, },
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )

View File

@ -1,9 +1,42 @@
package util package util
import network.KisTradeService
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
object MarketUtil { 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 { fun isKoreanMarketOpen(): Boolean {
// 한국 시간대 기준 현재 시간 가져오기 // 한국 시간대 기준 현재 시간 가져오기
val now = LocalTime.now(ZoneId.of("Asia/Seoul")) val now = LocalTime.now(ZoneId.of("Asia/Seoul"))

BIN
src/main/resources/neko.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB