From a320dcde027b3863d658487fdcae1fea77aee54c Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 10 Apr 2026 17:15:31 +0900 Subject: [PATCH] ... --- src/main/kotlin/service/AutoTradingManager.kt | 6 +- src/main/kotlin/ui/TradingDecisionLog.kt | 323 ++++++++++++------ 2 files changed, 224 insertions(+), 105 deletions(-) diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 67a0830..953e493 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -766,7 +766,7 @@ object AutoTradingManager { lastForceCheckMinute = currentMinute // 실행 완료 기록 } } - else if((now.hour == 8 || now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 1) || (currentMinute % 10 == 6)) { + else if((now.hour == 8 || now.hour == 16 || now.hour == 17 || now.hour == 18 || now.hour == 19) && (currentMinute % 10 == 1) || (currentMinute % 10 == 6)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer( " - ", @@ -774,9 +774,9 @@ object AutoTradingManager { "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", true ) - var list = mutableListOf("x") + var list = mutableListOf("X") if (now.hour != 8) { - list.add("y") + list.add("Y") } list.forEach { code -> KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt index 544f7d9..2294d13 100644 --- a/src/main/kotlin/ui/TradingDecisionLog.kt +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -205,55 +205,49 @@ fun TradingDecisionLog() { } Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) { LazyVerticalGrid( - columns = GridCells.Fixed(2), // 2열 병렬 배치 + columns = GridCells.Fixed(6), // 💡 2와 3의 최소공배수인 6열로 통합! horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth().weight(0.5f).background(Color.White) + modifier = Modifier.fillMaxSize().background(Color.White) // fillMaxWidth() 대신 전체 채우기 ) { + // ========================================== + // 1️⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3) + // ========================================== var firstSet = mutableSetOf() - item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + + item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지 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, + 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 -> + + // 💡 items에 span을 주어 3칸씩 차지하게 만듦 (결과적으로 2열 배치) + items(defaults.size, span = { GridItemSpan(3) }) { index -> val configKey = defaults.get(index) + var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } - // 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) + newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) } if (firstSet.contains(configKey)) { - TradingLogStore.addSettingLog(configKey.label,oldValue.toString(),newValue.toString(),"💾 저장됨: ${configKey.label} = $newValue") + 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")) { @@ -264,55 +258,114 @@ fun TradingDecisionLog() { OutlinedTextField( value = text, - onValueChange = { localText = it }, // 화면에는 즉시 반영 + 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() - } - ), + .onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal), + keyboardActions = KeyboardActions(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열을 모두 차지함 + // --- 🛡️ 수익 및 리스크 관리 섹션 --- + item(span = { GridItemSpan(maxLineSpan) }) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + Divider(color = Color.LightGray, thickness = 1.dp) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "🛡️ 수익 및 리스크 관리", + style = MaterialTheme.typography.subtitle2, + fontWeight = FontWeight.ExtraBold, + color = Color.DarkGray, + modifier = Modifier.padding(bottom = 12.dp) + ) + } + } + + // 💡 2열 배치니까 각각 span = 3 + item(span = { GridItemSpan(3) }) { + SettingSwitchField( + label = "자동 익절 활성화", + initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) == 1.0, + onCheckedChange = { KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0) }, + helperText = "목표 수익률 도달 시 기계적 익절" + ) + } + + item(span = { GridItemSpan(3) }) { + SettingSwitchField( + label = "자동 손절 활성화", + initialChecked = KisSession.config.getValues(ConfigIndex.STOP_LOSS) == 1.0, + onCheckedChange = { KisSession.config.setValues(ConfigIndex.STOP_LOSS, if (it) 1.0 else 0.0) }, + helperText = "손실 방어선 도달 시 기계적 손절" + ) + } + + item(span = { GridItemSpan(3) }) { + SettingInputField( + label = "최소 손절 라인 (%)", + initialValue = KisSession.config.getValues(ConfigIndex.LOSS_MINRATE).toString(), + placeholder = "-1.5", + onSave = { KisSession.config.setValues(ConfigIndex.LOSS_MINRATE, it.toDoubleOrNull() ?: -1.5) }, + helperText = "타협 가능한 최소 라인" + ) + } + + item(span = { GridItemSpan(3) }) { + SettingInputField( + label = "최대 허용 손절률 (%)", + initialValue = KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE).toString(), + placeholder = "-5.0", + onSave = { KisSession.config.setValues(ConfigIndex.LOSS_MAXRATE, it.toDoubleOrNull() ?: -5.0) }, + helperText = "절대 방어선 (기계적 매도)" + ) + } + + // 💡 금액은 길게 뻗어야 하니 maxLineSpan + item(span = { GridItemSpan(maxLineSpan) }) { + SettingInputField( + label = "최대 허용 손실 금액 (원)", + initialValue = KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY).toLong().toString(), + placeholder = "50000", + onSave = { KisSession.config.setValues(ConfigIndex.LOSS_MAX_MONEY, it.toDoubleOrNull() ?: 0.0) }, + helperText = "1종목당 허용할 수 있는 최대 손실액 (원 단위)" + ) + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Divider(color = Color.LightGray, thickness = 1.dp) + Spacer(modifier = Modifier.height(24.dp)) + } + } + + // ========================================== + // 2️⃣ 두 번째 섹션: 3열 배치 구간 (span = 2) + // ========================================== + item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지 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), + 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열을 모두 차지함 + val common = findLongestCommonSubstring(items.first().label, items.last().label) + + item(span = { GridItemSpan(maxLineSpan) }) { Text( common, style = MaterialTheme.typography.body1, @@ -320,72 +373,42 @@ fun TradingDecisionLog() { ) } - items(items.size) { index -> + // 💡 items에 span을 주어 2칸씩 차지하게 만듦 (결과적으로 3열 배치) + items(items.size, span = { GridItemSpan(2) }) { 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() ?: "") - } + var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } + var labelText by remember(configKey) { mutableStateOf("") } 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") + 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 업데이트 로직 (기존과 동일) 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() }원 투자}" + getRemaining(configKey.label, common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 비율(${KisSession.config.getValues(configKey)}) + 세금(${KisSession.config.getValues(ConfigIndex.TAX_INDEX)}) = ${(localText.toDoubleOrNull() ?: 0.0) * 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.toDoubleOrNull() ?: 0.0)}원" } else { - getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" + getRemaining(configKey.label, common) + ": -${localText} 호가 매수" } OutlinedTextField( value = localText, - onValueChange = { localText = it }, // 화면에는 즉시 반영 + 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() - } - ), + .onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal), + keyboardActions = KeyboardActions(onDone = { saveAction() }), singleLine = true ) } @@ -533,3 +556,99 @@ fun CsvDropZone( ) } } + + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SettingInputField( + label: String, + initialValue: String, // 💡 value -> initialValue 로 변경 + placeholder: String = "", + helperText: String = "", + onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경 +) { + // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책) + var localText by remember { mutableStateOf(initialValue) } + + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = localText, + onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영 + label = { Text(label, fontWeight = FontWeight.Bold) }, + placeholder = { Text(placeholder) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + // 💡 포커스를 잃었을 때 (다른 칸을 클릭했을 때) 저장 + if (!focusState.isFocused) { + onSave(localText) + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Decimal + ), + keyboardActions = KeyboardActions( + // 💡 모바일 키보드나 키보드에서 엔터(Done) 쳤을 때 저장 + onDone = { + onSave(localText) + } + ) + ) + + if (helperText.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = helperText, + color = Color.Gray, + fontSize = 11.sp, + modifier = Modifier.padding(start = 4.dp) + ) + } + } +} + +@Composable +fun SettingSwitchField( + label: String, + initialChecked: Boolean, + helperText: String = "", + onCheckedChange: (Boolean) -> Unit +) { + // 💡 스위치 애니메이션을 즉각 보여주기 위한 로컬 상태 + var localChecked by remember { mutableStateOf(initialChecked) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = label, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Switch( + checked = localChecked, + onCheckedChange = { isChecked -> + localChecked = isChecked // 화면 스위치 즉시 변경 + onCheckedChange(isChecked) // 실제 DB/설정 저장 트리거 + } + ) + } + + if (helperText.isNotEmpty()) { + Text( + text = helperText, + color = Color.Gray, + fontSize = 11.sp, + modifier = Modifier.padding(start = 2.dp) + ) + } + } +}