atrade/src/main/kotlin/ui/TradingDecisionLog.kt
2026-06-01 17:55:10 +09:00

1052 lines
48 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import model.ConfigIndex
import model.KisSession
import network.KisTradeService
import network.NewsService
import network.StockUniverseLoader
import service.AutoTradingManager
import java.io.File
import java.net.URI
import kotlin.math.abs
@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", "SETTING","ANALYZER","WATCH","AFTER","NOTICE")//"PASS",,"RETRY""HOLD",
var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) }
val tradeConfig by remember {
CoroutineScope(Dispatchers.Default).launch {
println("CALLED sendTelegramMessage -1")
val now = java.time.LocalTime.now(java.time.ZoneId.of("Asia/Seoul"))
NewsService.sendTelegramMessage("⏰ 자동 실행 시간(${now.hour}:${now.minute})입니다. 시스템을 가동합니다.")
}
mutableStateOf(KisSession.tradeConfig)
}
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 scrollState = rememberScrollState()
val filterScrollState = rememberScrollState()
// [핵심] 원본 로그에서 필터 조건에 맞는 리스트만 산출
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(1f).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(modifier = Modifier
.fillMaxWidth()
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
filterOptions.forEach { option ->
val isSelected = selectedFilters.contains(option)
FilterChipWithRightClick(
isSelected = 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
},
onClear = {
if (option != "전체") {
// 예: 로그의 등급(investmentGrade)이나 결정(decision)이 필터명과 일치할 때 삭제
TradingLogStore.decisionLogs.removeIf { log ->
log.decision.contains(option)
}
} else {
// "전체" 필터에서 우클릭 시 모든 로그 삭제
TradingLogStore.decisionLogs.clear()
}
// 💡 2. UI 동기화 (선택 해제)
val newFilters = selectedFilters.toMutableSet()
newFilters.remove(option)
if (newFilters.isEmpty()) newFilters.add("전체")
selectedFilters = newFilters
},
label = option,
scrollState = scrollState
)
}
}
}
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)
if (log.decision.contains("NOTICE") && log.reason.contains("[손절 경고]")) {
Button(
onClick = {
// 종목 코드 추출 로직 (로그 메시지 형식에 따라 조절 필요)
// 예: "[손절 경고] 삼성전자(005930) ..." 형식일 경우
val stockCode = log.stockName.substringAfter("[").substringBefore("]")
if (stockCode.length == 6) {
log?.stockCode?.let { code ->
coroutineScope.launch {
KisTradeService.postOrder(stockCode = code, qty= log.qty.toString(), price = "", isBuy = false).onSuccess {
TradingLogStore.addSellLog(
"${log.stockName}[${log.stockCode}]",
"시장가",
"SELL",
"수동 손절 주문 완료"
)
}.onFailure { error ->
}
}
}
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
modifier = Modifier.height(30.dp).padding(start = 8.dp)
) {
Text("즉시 매도", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
}
Column(modifier = Modifier.weight(1f).padding(6.dp).fillMaxHeight().background(Color.White)) {
LazyVerticalGrid(
columns = GridCells.Fixed(6), // 💡 2와 3의 최소공배수인 6열로 통합!
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxSize().background(Color.White) // fillMaxWidth() 대신 전체 채우기
) {
// ==========================================
// 1⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3)
// ==========================================
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,
)
// 💡 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() ?: "") }
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)
}
KisSession.config.setValues(configKey, 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 -> if (!focusState.isFocused) saveAction() },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal),
keyboardActions = KeyboardActions(onDone = { saveAction() }),
singleLine = true
)
}
// --- 🛡️ 수익 및 리스크 관리 섹션 ---
item(span = { GridItemSpan(maxLineSpan) }) {
Column {
Spacer(modifier = Modifier.height(20.dp))
Divider(color = Color.LightGray, thickness = 1.dp)
Spacer(modifier = Modifier.height(10.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) > 0.0,
onCheckedChange = {
KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0)
},
helperText = "목표 수익률 ${KisSession.config.SELL_PROFIT} 도달 시 기계적 매도"
)
}
item(span = { GridItemSpan(3) }) {
SettingSwitchField(
label = "자동 손절 활성화",
initialChecked = KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.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(10.dp))
Divider(color = Color.LightGray, thickness = 1.dp)
Spacer(modifier = Modifier.height(20.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),
)
for (items in defaults2) {
val common = findLongestCommonSubstring(items.first().label, items.last().label)
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
common,
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
)
}
// 💡 items에 span을 주어 2칸씩 차지하게 만듦 (결과적으로 3열 배치)
items(items.size, span = { GridItemSpan(2) }) { index ->
val configKey = items.get(index)
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)
}
// 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.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} 호가 매수"
}
OutlinedTextField(
value = localText,
onValueChange = { localText = it },
label = { Text(labelText) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal),
keyboardActions = KeyboardActions(onDone = { saveAction() }),
singleLine = true
)
}
}
}
}
Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White).verticalScroll(rememberScrollState())) {
Text("🕒 시간 및 매매 스케줄", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp))
Card(elevation = 2.dp, modifier = Modifier.fillMaxWidth().padding(4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
Text("자동 매매 시간 설정", fontWeight = FontWeight.Bold, color = Color.Blue)
Spacer(Modifier.height(8.dp))
// 시작 시간 (HH:mm)
TimeInputRow("자동 시작", tradeConfig.auto_start_time) { tradeConfig.auto_start_time = it
KisSession.saveTradeConfig() }
// 종료 시간 (HH:mm)
TimeInputRow("자동 종료", tradeConfig.auto_end_time) { tradeConfig.auto_end_time = it
KisSession.saveTradeConfig() }
Divider(Modifier.padding(vertical = 8.dp))
// 매수 금지 시간 설정
TimeInputRow("매수 시작", tradeConfig.start_buy_time) { tradeConfig.start_buy_time = it
KisSession.saveTradeConfig() }
TimeInputRow("매수 종료", tradeConfig.end_buy_time) { tradeConfig.end_buy_time = it
KisSession.saveTradeConfig() }
}
}
Spacer(Modifier.height(16.dp))
Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "매수 분석 현 변동율 처저 기준",
initialValue = (tradeConfig.minusFilter * -1).toString(),
onSave = {
tradeConfig.minusFilter = abs(it.toDouble())
KisSession.saveTradeConfig()
},
helperText = "현제 변동율이 이것보다 커야 분석 함."
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "매수 분석 현 변동율 최고 기준",
initialValue = (tradeConfig.plusFilter).toString(),
onSave = {
tradeConfig.plusFilter = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "현제 변동율이 이것보다 작아야 분석 함."
)
}
// Boolean 설정들
SettingSwitchField(
label = "미체결 자동 취소 (매수)",
initialChecked = tradeConfig.auto_cancel_pending_buy,
helperText = "지정 시간 경과 시 미체결 매수 주문 취소",
onCheckedChange = { tradeConfig.auto_cancel_pending_buy = it
KisSession.saveTradeConfig()
}
)
SettingInputField(
label = "자동 취소 대기 시간 (분)",
initialValue = (tradeConfig.auto_cancel_pending_time / (60 * 1000)).toString(),
onSave = {
val minutes = it.toLongOrNull() ?: 60L
tradeConfig.auto_cancel_pending_time = minutes * 60 * 1000
KisSession.saveTradeConfig()
},
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
)
SettingInputField(
label = "자동 취소 갭 비율",
initialValue = (tradeConfig.auto_cancel_pending_rate).toString(),
onSave = {
val rate = it.toDoubleOrNull() ?: 2.0
tradeConfig.auto_cancel_pending_rate = rate
KisSession.saveTradeConfig()
},
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
)
SettingSwitchField(
label = "장 전 대체마켓 매도",
initialChecked = tradeConfig.before_nxt,
onCheckedChange = { tradeConfig.before_nxt = it
KisSession.saveTradeConfig() }
)
SettingSwitchField(
label = "장 후 대체 마켓 매도",
initialChecked = tradeConfig.after_nxt,
onCheckedChange = { tradeConfig.after_nxt = it
KisSession.saveTradeConfig() }
)
SettingSwitchField(
label = "해외 주식",
initialChecked = tradeConfig.enableOverSea,
onCheckedChange = { tradeConfig.enableOverSea = it
KisSession.saveTradeConfig() }
)
SettingInputField(
label = "특정 메시지를 수신하려면 텔레그램 아뒤 입력",
initialValue = (tradeConfig.tlg_id).toString(),
onSave = {
tradeConfig.tlg_id = it
KisSession.saveTradeConfig()
},
helperText = "본인의 텔레그램 아뒤"
)
SettingSwitchField(
label = "물타기",
initialChecked = tradeConfig.lowerAveragePrice,
onCheckedChange = { tradeConfig.lowerAveragePrice = it
KisSession.saveTradeConfig() }
)
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 최저선",
initialValue = (tradeConfig.lowerAverageMaxRate).toString(),
onSave = {
tradeConfig.lowerAverageMaxRate = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이것보다 커야 삼"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 최고선",
initialValue = (tradeConfig.lowerAverageMinRate).toString(),
onSave = {
tradeConfig.lowerAverageMinRate = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이것보다 작아야 삼"
)
}
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 기준 최소 보유 수량",
initialValue = (tradeConfig.lowerAverageTargetCount).toString(),
onSave = {
tradeConfig.lowerAverageTargetCount = it.toInt()
KisSession.saveTradeConfig()
},
helperText = "이거 이상 갖고 있어야 삼"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 개수",
initialValue = (tradeConfig.lowerAverageStockCount).toString(),
onSave = {
tradeConfig.lowerAverageStockCount = it.toInt()
KisSession.saveTradeConfig()
},
helperText = "1만큼 사고 팜"
)
}
}
}
}
@Composable
fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? = null) {
Row(
verticalAlignment = 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<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>()
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<Pair<String, String>>) -> 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
)
}
}
//@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)
// )
// }
// }
//}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SettingInputField(
modifier: Modifier? = null,
label: String,
initialValue: String, // 💡 value -> initialValue 로 변경
placeholder: String = "",
helperText: String = "",
onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경
) {
// 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
var localText by remember { mutableStateOf(initialValue) }
Column(modifier = 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 = 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)
)
}
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
@Composable
fun FilterChipWithRightClick(
label: String,
isSelected: Boolean,
onClick: () -> Unit,
onClear: () -> Unit,
scrollState: ScrollState // 스크롤 제어를 위해 추가
) {
var showMenu by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var componentOffset by remember { mutableStateOf(0f) }
var componentWidth by remember { mutableStateOf(0) } // 칩 너비 추가
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
componentOffset = coordinates.positionInParent().x
componentWidth = coordinates.size.width // 칩의 실제 너비 저장
}
) {
Surface(
modifier = Modifier
.padding(end = 6.dp)
.pointerInput(label) {
detectTapGestures(
onTap = {
onClick()
coroutineScope.launch {
// 부모(Row)의 전체 너비 가져오기
val containerWidth = scrollState.maxValue + scrollState.viewportSize
val viewportWidth = scrollState.viewportSize
// 🎯 중앙 정렬 공식:
// (칩의 절대 위치) - (화면 절반) + (칩 너비의 절반)
val targetScroll = (scrollState.value + componentOffset + (componentWidth / 2)) - (viewportWidth / 2)
// 0과 최대 스크롤 범위 사이로 제한하여 부드럽게 이동
scrollState.animateScrollTo(
targetScroll.toInt().coerceIn(0, scrollState.maxValue)
)
}
}
)
}
// 마우스 우클릭 별도 감지
.onPointerEvent(PointerEventType.Press) { event ->
if (event.buttons.isSecondaryPressed) showMenu = true
},
shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFF0E62CF) else Color(0xFFF0F2F5),
border = BorderStroke(1.dp, if (isSelected) Color(0xFF0E62CF) else Color.LightGray)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
fontSize = 11.sp,
color = if (isSelected) Color.White else Color.Black
)
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(onClick = {
onClear()
showMenu = false
}) {
Text(if (label == "전체") "전체 로그 초기화" else "'$label' 삭제", color = Color.Red)
}
}
}
}
/**
* 시:분 입력을 위한 전용 컴포저블
*/
@Composable
fun TimeInputRow(label: String, initialTime: String, onTimeChange: (String) -> Unit) {
// 💡 화면에 즉시 글자를 보여주기 위한 로컬 상태
var localText by remember(initialTime) { mutableStateOf(initialTime) }
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(label, modifier = Modifier.width(80.dp), fontSize = 13.sp, fontWeight = FontWeight.Medium)
OutlinedTextField(
value = localText,
onValueChange = {
// 5글자(HH:mm) 제한 및 입력 제어
if (it.length <= 5) {
localText = it
}
},
placeholder = { Text("00:00") },
modifier = Modifier
.weight(1f)
.onFocusChanged { focusState ->
// 💡 포커스를 잃었을 때(입력 완료 시) 실제 데이터 객체에 저장
if (!focusState.isFocused) {
onTimeChange(localText)
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Number // 숫자 키패드 유도
),
keyboardActions = KeyboardActions(
onDone = { onTimeChange(localText) }
),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 14.sp)
)
}
}