atrade/src/main/kotlin/ui/TradingDecisionLog.kt
2026-04-03 17:47:56 +09:00

398 lines
20 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
// }
// }
}
}