398 lines
20 KiB
Kotlin
398 lines
20 KiB
Kotlin
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.lazy.rememberLazyListState
|
||
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 kotlinx.coroutines.launch
|
||
import model.ConfigIndex
|
||
import model.KisSession
|
||
import service.AutoTradingManager
|
||
|
||
@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")
|
||
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))
|
||
|
||
// [수정] 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
|
||
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<ConfigIndex>()
|
||
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
|
||
)
|
||
}
|
||
|
||
}
|
||
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<ConfigIndex>()
|
||
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)
|
||
// }
|
||
// }
|
||
}
|
||
}
|