This commit is contained in:
lunaticbum 2026-04-10 17:15:31 +09:00
parent 7d03cc78b1
commit a320dcde02
2 changed files with 224 additions and 105 deletions

View File

@ -766,7 +766,7 @@ object AutoTradingManager {
lastForceCheckMinute = currentMinute // 실행 완료 기록 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) { if (lastForceCheckMinute != currentMinute) {
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
" - ", " - ",
@ -774,9 +774,9 @@ object AutoTradingManager {
"⏰ [강제 스케줄 실행] 오후 ${now.hour}${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", "⏰ [강제 스케줄 실행] 오후 ${now.hour}${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.",
true true
) )
var list = mutableListOf<String>("x") var list = mutableListOf<String>("X")
if (now.hour != 8) { if (now.hour != 8) {
list.add("y") list.add("Y")
} }
list.forEach { code -> list.forEach { code ->
KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {

View File

@ -205,55 +205,49 @@ fun TradingDecisionLog() {
} }
Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) { Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), // 2열 병렬 배치 columns = GridCells.Fixed(6), // 💡 2와 3의 최소공배수인 6열로 통합!
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = 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<ConfigIndex>() var firstSet = mutableSetOf<ConfigIndex>()
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지
Text( Text(
"💰 거래 기본 설정", "💰 거래 기본 설정",
style = MaterialTheme.typography.subtitle2, style = MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
) )
} }
var defaults = arrayOf( var defaults = arrayOf(
ConfigIndex.TAX_INDEX, ConfigIndex.TAX_INDEX, ConfigIndex.PROFIT_INDEX, ConfigIndex.BUY_WEIGHT_INDEX,
ConfigIndex.PROFIT_INDEX, ConfigIndex.MAX_BUDGET_INDEX, ConfigIndex.MAX_PRICE_INDEX, ConfigIndex.MIN_PRICE_INDEX,
ConfigIndex.BUY_WEIGHT_INDEX, ConfigIndex.MIN_PURCHASE_SCORE_INDEX, ConfigIndex.SELL_PROFIT,
ConfigIndex.MAX_BUDGET_INDEX, ConfigIndex.MAX_COUNT_INDEX, ConfigIndex.MAX_HOLDING_COUNT,
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) 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 = { val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0 var newValue = localText.toDoubleOrNull() ?: 0.0
var oldValue = KisSession.config.getValues(configKey) var oldValue = KisSession.config.getValues(configKey)
if (configKey.label.contains("PROFIT")) { 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)) { 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 { } else {
firstSet.add(configKey) firstSet.add(configKey)
} }
KisSession.config.setValues(configKey, newValue) KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config) DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue")
} }
var text = if (configKey.label.contains("PROFIT")) { var text = if (configKey.label.contains("PROFIT")) {
@ -264,55 +258,114 @@ fun TradingDecisionLog() {
OutlinedTextField( OutlinedTextField(
value = text, value = text,
onValueChange = { localText = it }, // 화면에는 즉시 반영 onValueChange = { localText = it },
label = { Text(configKey.label) }, label = { Text(configKey.label) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { focusState -> .onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() },
// 2. 포커스를 잃었을 때 저장 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal),
if (!focusState.isFocused) { keyboardActions = KeyboardActions(onDone = { saveAction() }),
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true singleLine = true
) )
} }
} // --- 🛡️ 수익 및 리스크 관리 섹션 ---
LazyVerticalGrid( item(span = { GridItemSpan(maxLineSpan) }) {
columns = GridCells.Fixed(3), // 2열 병렬 배치 Column {
horizontalArrangement = Arrangement.spacedBy(6.dp), Spacer(modifier = Modifier.height(24.dp))
verticalArrangement = Arrangement.spacedBy(6.dp), Divider(color = Color.LightGray, thickness = 1.dp)
modifier = Modifier.fillMaxWidth().weight(0.5f).background(Color.White) Spacer(modifier = Modifier.height(16.dp))
) { Text(
var firstSet = mutableSetOf<ConfigIndex>() text = "🛡️ 수익 및 리스크 관리",
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 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( Text(
"💰매수 정책 및 기대 수익률", "💰 매수 정책 및 기대 수익률",
style = MaterialTheme.typography.subtitle2, style = MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
) )
} }
var defaults2 = arrayOf( var defaults2 = arrayOf(
arrayOf(ConfigIndex.GRADE_5_BUY, ConfigIndex.GRADE_5_PROFIT,ConfigIndex.GRADE_5_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_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_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_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_1_BUY, ConfigIndex.GRADE_1_PROFIT, ConfigIndex.GRADE_1_ALLOCATIONRATE),
) )
for (items in defaults2) { for (items in defaults2) {
val common = findLongestCommonSubstring(items.first().label,items.last().label) val common = findLongestCommonSubstring(items.first().label, items.last().label)
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
item(span = { GridItemSpan(maxLineSpan) }) {
Text( Text(
common, common,
style = MaterialTheme.typography.body1, 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) val configKey = items.get(index)
var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") }
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) var labelText by remember(configKey) { mutableStateOf("") }
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
var labelText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
val saveAction = { val saveAction = {
var oldValue = KisSession.config.getValues(configKey) var oldValue = KisSession.config.getValues(configKey)
var newValue = localText.toDoubleOrNull() ?: 0.0 var newValue = localText.toDoubleOrNull() ?: 0.0
//
KisSession.config.setValues(configKey, newValue) KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config) DatabaseFactory.saveConfig(KisSession.config)
if (firstSet.contains(configKey)) { 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 { } else {
firstSet.add(configKey) 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")) { labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( 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)}"
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( } else if (configKey.name.contains("ALLOCATIONRATE")) {
ConfigIndex.TAX_INDEX)} " getRemaining(configKey.label, common) + ": 최대 ${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * (localText.toDoubleOrNull() ?: 0.0)}"
} else if (configKey.name.contains("ALLOCATIONRATE")) {
getRemaining(configKey.label,common) + ": 최대 ${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * localText.toDouble() }원 투자}"
} else { } else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" getRemaining(configKey.label, common) + ": -${localText} 호가 매수"
} }
OutlinedTextField( OutlinedTextField(
value = localText, value = localText,
onValueChange = { localText = it }, // 화면에는 즉시 반영 onValueChange = { localText = it },
label = { Text(labelText) }, label = { Text(labelText) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { focusState -> .onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() },
// 2. 포커스를 잃었을 때 저장 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal),
if (!focusState.isFocused) { keyboardActions = KeyboardActions(onDone = { saveAction() }),
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true 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)
)
}
}
}