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 설정
}
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 // 시작 메뉴 등록
}
}
}
}

View File

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

View File

@ -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 상태로 시작)

View File

@ -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<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] 통합 잔고 조회 (국내 + 해외 합산)
*/

View File

@ -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<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>()
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 ->
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 {

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB