...
This commit is contained in:
parent
7ce24cc631
commit
d804c7061b
@ -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 // 시작 메뉴 등록
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 상태로 시작)
|
||||
|
||||
@ -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] 통합 잔고 조회 (국내 + 해외 합산)
|
||||
*/
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
BIN
src/main/resources/neko.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Loading…
x
Reference in New Issue
Block a user