package ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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 import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import model.ConfigIndex import model.KisSession import service.AutoTradingManager @OptIn(ExperimentalMaterialApi::class) @Composable fun TradingDecisionLog() { var searchQuery by remember { mutableStateOf("") } var selectedFilters by remember { mutableStateOf(setOf("전체")) } val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS") var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) } LaunchedEffect(AutoTradingManager.llmAnalyser) { llmAnalyser = AutoTradingManager.llmAnalyser } var llmNews by remember { mutableStateOf(AutoTradingManager.llmNews) } LaunchedEffect(AutoTradingManager.llmNews) { llmNews = AutoTradingManager.llmNews } var tradeToken by remember { mutableStateOf(AutoTradingManager.tradeToken) } LaunchedEffect(AutoTradingManager.tradeToken) { tradeToken = AutoTradingManager.tradeToken } var webSocketConnect by remember { mutableStateOf(AutoTradingManager.webSocketConnect) } LaunchedEffect(AutoTradingManager.webSocketConnect) { webSocketConnect = AutoTradingManager.webSocketConnect } // [핵심] 원본 로그에서 필터 조건에 맞는 리스트만 산출 val filteredLogs = TradingLogStore.decisionLogs.filter { log -> val matchesType = if (selectedFilters.contains("전체")) { true } else { selectedFilters.contains(log.decision) } 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) Row( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.Start ) { StatusIndicator("주식 분석기",llmAnalyser) StatusIndicator("뉴스 처리기",llmNews) StatusIndicator("한투 인증서",tradeToken) StatusIndicator("실시간 감시",webSocketConnect) } // [추가] 상단 검색 및 필터 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 = selectedFilters.contains(option) FilterChip( selected = isSelected, onClick = { val newFilters = selectedFilters.toMutableSet() if (option == "전체") { // "전체" 클릭 시 나머지는 다 지우고 전체만 남김 newFilters.clear() newFilters.add("전체") } else { // 다른 필터 클릭 시 newFilters.remove("전체") if (newFilters.contains(option)) { newFilters.remove(option) } else { newFilters.add(option) } // 만약 아무것도 선택 안 된 상태라면 다시 "전체" 선택 if (newFilters.isEmpty()) { newFilters.add("전체") } } selectedFilters = newFilters }, 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 ) { Column(modifier = Modifier.padding(12.dp)) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Text("${log.time} - ${log.stockName}", fontWeight = FontWeight.Bold) Text( text = log.decision, color = when (log.decision) { "BUY" -> Color.Red "SETTING" -> Color(0xFFFFA500) "SELL" -> Color(0xFF800080) "HOLD" -> Color.DarkGray "ANALYZER" -> Color.Green "PASS" -> Color.Yellow else -> Color.DarkGray }, fontWeight = FontWeight.ExtraBold ) } Text("신뢰도: ${log.confidence}%", fontSize = 11.sp) Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray) } } } } } Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) { LazyVerticalGrid( columns = GridCells.Fixed(2), // 2열 병렬 배치 horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().fillMaxHeight().background(Color.White) ) { var firstSet = mutableSetOf() item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 Text( "💰 거래 기본 설정", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) ) } var defaults = arrayOf( ConfigIndex.TAX_INDEX, ConfigIndex.PROFIT_INDEX, ConfigIndex.BUY_WEIGHT_INDEX, ConfigIndex.MAX_BUDGET_INDEX, ConfigIndex.MAX_PRICE_INDEX, ConfigIndex.MIN_PRICE_INDEX, ConfigIndex.MIN_PURCHASE_SCORE_INDEX, ConfigIndex.SELL_PROFIT, ConfigIndex.MAX_COUNT_INDEX, ) items(defaults.size) { index -> val configKey = defaults.get(index) // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } // 저장 로직을 공통 함수로 분리 val saveAction = { var newValue = localText.toDoubleOrNull() ?: 0.0 var oldValue = KisSession.config.getValues(configKey) if (configKey.label.contains("PROFIT")) { newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) } if (firstSet.contains(configKey)) { TradingLogStore.addSettingLog(configKey.label,oldValue.toString(),newValue.toString(),"💾 저장됨: ${configKey.label} = $newValue") } else { firstSet.add(configKey) } KisSession.config.setValues(configKey, newValue) DatabaseFactory.saveConfig(KisSession.config) println("💾 저장됨: ${configKey.label} = $newValue") } var text = if (configKey.label.contains("PROFIT")) { "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" } else { localText } OutlinedTextField( value = text, onValueChange = { localText = it }, // 화면에는 즉시 반영 label = { Text(configKey.label) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> // 2. 포커스를 잃었을 때 저장 if (!focusState.isFocused) { saveAction() } }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal ), keyboardActions = KeyboardActions( // 3. 엔터(Done) 키를 눌렀을 때 저장 onDone = { saveAction() } ), singleLine = true ) } item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 Text( "💰매수 정책 및 기대 수익률", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) ) } var defaults2 = arrayOf( arrayOf(ConfigIndex.GRADE_5_BUY, ConfigIndex.GRADE_5_PROFIT,), arrayOf(ConfigIndex.GRADE_4_BUY, ConfigIndex.GRADE_4_PROFIT,), arrayOf(ConfigIndex.GRADE_3_BUY, ConfigIndex.GRADE_3_PROFIT,), arrayOf(ConfigIndex.GRADE_2_BUY, ConfigIndex.GRADE_2_PROFIT,), arrayOf(ConfigIndex.GRADE_1_BUY, ConfigIndex.GRADE_1_PROFIT,), ) for (items in defaults2) { val common = findLongestCommonSubstring(items.first().label,items.last().label) item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 Text( common, style = MaterialTheme.typography.body1, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) ) } items(items.size) { index -> val configKey = items.get(index) // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } var labelText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } val saveAction = { var oldValue = KisSession.config.getValues(configKey) var newValue = localText.toDoubleOrNull() ?: 0.0 // KisSession.config.setValues(configKey, newValue) DatabaseFactory.saveConfig(KisSession.config) if (firstSet.contains(configKey)) { TradingLogStore.addSettingLog(configKey.label,oldValue.toString(),newValue.toString(),"💾 저장됨: ${configKey.label} = $newValue") } else { firstSet.add(configKey) } labelText = if (configKey.name.contains("PROFIT")) { getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( ConfigIndex.TAX_INDEX)}" } else { getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" } } labelText = if (configKey.name.contains("PROFIT")) { getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( ConfigIndex.TAX_INDEX)} " } else { getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" } OutlinedTextField( value = localText, onValueChange = { localText = it }, // 화면에는 즉시 반영 label = { Text(labelText) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> // 2. 포커스를 잃었을 때 저장 if (!focusState.isFocused) { saveAction() } }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal ), keyboardActions = KeyboardActions( // 3. 엔터(Done) 키를 눌렀을 때 저장 onDone = { saveAction() } ), singleLine = true ) } } } } } } @Composable fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? = null) { Row( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp) ) { Text(text = label, style = MaterialTheme.typography.body2) Spacer(Modifier.width(4.dp)) // 상태 아이콘 (초록 O / 빨간 X) Text( text = if (isActive) "●" else "×", color = if (isActive) Color.Green else Color.Red, fontSize = 18.sp, fontWeight = FontWeight.Bold ) // 문제가 있을 때(false)만 재구동 유도 버튼 표시 // if (!isActive && onRestart != null) { // TextButton(onClick = onRestart, contentPadding = PaddingValues(0.dp)) { // Text("재구동", color = Color.Blue, fontSize = 11.sp, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline) // } // } } }