package ui import androidx.compose.foundation.background import androidx.compose.foundation.border 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.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.Alignment import androidx.compose.ui.DragData import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.onExternalDrag 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 kotlinx.coroutines.launch import model.ConfigIndex import model.KisSession import network.StockUniverseLoader import service.AutoTradingManager import java.io.File import java.net.URI @OptIn(ExperimentalMaterialApi::class) @Composable fun TradingDecisionLog() { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var searchQuery by remember { mutableStateOf("") } var selectedFilters by remember { mutableStateOf(setOf("전체")) } val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER","NOTICE") 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)) { Button( onClick = { coroutineScope.launch { // index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0)) listState.animateScrollToItem(filteredLogs.size - 1) } } ) { 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)) CsvDropZone( onUniverseUpdated = { updatedList -> // UI 갱신 (필요한 경우) // currentUniverse = updatedList // 💡 봇의 실제 작업 큐인 loadedTops 에도 갱신된 데이터를 덮어씌워 줌 AutoTradingManager.loadedTops.clear() AutoTradingManager.loadedTops.addAll(updatedList) AutoTradingManager.loadedTops.shuffle() // 섞어주면 편향 분석 방지! } ) Spacer(modifier = Modifier.height(16.dp)) // [수정] filteredLogs를 사용하여 최신 로그가 위로 오게 표시 LazyColumn( state = listState, 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 "RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열) "WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시) "AFTER" -> Color.Red "NOTICE" ->Color(0xFF1E88E5) else -> Color.DarkGray }, fontWeight = FontWeight.ExtraBold ) } when (log.decision) { "BUY", "SELL", "HOLD" -> 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().weight(0.5f).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, ConfigIndex.MAX_HOLDING_COUNT, ) 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 ) } } LazyVerticalGrid( columns = GridCells.Fixed(3), // 2열 병렬 배치 horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().weight(0.5f).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 defaults2 = arrayOf( arrayOf(ConfigIndex.GRADE_5_BUY, ConfigIndex.GRADE_5_PROFIT,ConfigIndex.GRADE_5_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_4_BUY, ConfigIndex.GRADE_4_PROFIT,ConfigIndex.GRADE_4_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_3_BUY, ConfigIndex.GRADE_3_PROFIT,ConfigIndex.GRADE_3_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_2_BUY, ConfigIndex.GRADE_2_PROFIT,ConfigIndex.GRADE_2_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_1_BUY, ConfigIndex.GRADE_1_PROFIT,ConfigIndex.GRADE_1_ALLOCATIONRATE), ) 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 if (configKey.name.contains("ALLOCATIONRATE")) { getRemaining(configKey.label,common) + ": 최대 ${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * newValue}원 투자}" } 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 if (configKey.name.contains("ALLOCATIONRATE")) { getRemaining(configKey.label,common) + ": 최대 ${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * localText.toDouble() }원 투자}" } 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) // } // } } } fun findLongestCommonSubstring(s1: String, s2: String): String { if (s1.isEmpty() || s2.isEmpty()) return "" var longest = "" // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임 val reference = if (s1.length <= s2.length) s1 else s2 val target = if (s1.length <= s2.length) s2 else s1 for (i in reference.indices) { for (j in (i + longest.length + 1)..reference.length) { val sub = reference.substring(i, j) if (target.contains(sub)) { if (sub.length > longest.length) { longest = sub } } else { // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출 break } } } return longest } fun getRemaining(original: String, common: String): String { if (common.isEmpty()) return original // 가장 처음 발견되는 공통 문자열을 한 번만 제거 return original.replaceFirst(common, "").trim() } fun parseSmartCsv(file: File): List> { val result = mutableListOf>() try { val lines = file.readLines() if (lines.isEmpty()) return result // 1. 헤더 분석하여 열(Column) 인덱스 자동 추적 val headers = lines[0].split(",").map { it.replace("\"", "").trim() } val codeIndex = headers.indexOfFirst { it.contains("종목코드") } val nameIndex = headers.indexOfFirst { it.contains("종목명") } // 헤더를 못 찾았다면 기본값 (0번: 코드, 1번: 이름)으로 폴백 val finalCodeIdx = if (codeIndex != -1) codeIndex else 0 val finalNameIdx = if (nameIndex != -1) nameIndex else 1 // 2. 데이터 추출 for (i in 1 until lines.size) { val line = lines[i] if (line.isBlank()) continue val parts = line.split(",").map { it.replace("\"", "").trim() } if (parts.size > maxOf(finalCodeIdx, finalNameIdx)) { val code = parts[finalCodeIdx] val name = parts[finalNameIdx] // 6자리 숫자로 된 정상적인 종목코드인지 검증 if (code.length == 6 && code.all { it.isDigit() }) { result.add(code to name) } } } } catch (e: Exception) { println("❌ CSV 파싱 에러: ${e.message}") } return result } @OptIn(ExperimentalComposeUiApi::class) @Composable fun CsvDropZone( onUniverseUpdated: (List>) -> Unit ) { var isDragging by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxWidth() .height(40.dp) .background(if (isDragging) Color(0xFFE3F2FD) else Color(0xFFFAFAFA)) .border( width = 1.dp, color = if (isDragging) Color.Blue else Color.LightGray, shape = RoundedCornerShape(8.dp) ) .onExternalDrag( onDragStart = { isDragging = true }, onDragExit = { isDragging = false }, onDrop = { state -> isDragging = false val dragData = state.dragData if (dragData is DragData.FilesList) { val fileUris = dragData.readFiles() fileUris.firstOrNull { it.endsWith(".csv", ignoreCase = true) }?.let { uri -> // 💡 여기서 깔끔하게 StockUniverseLoader 에 처리를 위임! val file = File(URI(uri)) val updatedUniverse = StockUniverseLoader.parseAndMergeCsv(file) onUniverseUpdated(updatedUniverse) } } } ), contentAlignment = Alignment.Center ) { Text( text = if (isDragging) "📥 파일을 놓아서 업데이트!" else "📁 [CSV 추가] 파일을 드래그하여 유니버스 자동 병합", color = if (isDragging) Color.Blue else Color.Gray, fontSize = 12.sp, fontWeight = FontWeight.SemiBold ) } }