2026-03-13 16:37:53 +09:00
|
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 17:09:54 +09:00
|
|
|
|
import androidx.compose.foundation.BorderStroke
|
|
|
|
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
2026-04-29 17:56:16 +09:00
|
|
|
|
import androidx.compose.foundation.ScrollState
|
2026-03-13 16:37:53 +09:00
|
|
|
|
import androidx.compose.foundation.background
|
2026-04-09 13:17:52 +09:00
|
|
|
|
import androidx.compose.foundation.border
|
2026-04-20 17:09:54 +09:00
|
|
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
2026-04-13 18:02:38 +09:00
|
|
|
|
import androidx.compose.foundation.horizontalScroll
|
2026-03-13 16:37:53 +09:00
|
|
|
|
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
|
2026-04-01 11:11:50 +09:00
|
|
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
2026-04-13 18:02:38 +09:00
|
|
|
|
import androidx.compose.foundation.rememberScrollState
|
2026-04-09 13:17:52 +09:00
|
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
2026-03-13 16:37:53 +09:00
|
|
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
|
|
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
2026-04-29 15:32:09 +09:00
|
|
|
|
import androidx.compose.foundation.verticalScroll
|
2026-03-13 16:37:53 +09:00
|
|
|
|
import androidx.compose.material.*
|
2026-03-20 17:55:27 +09:00
|
|
|
|
import androidx.compose.material.icons.Icons
|
|
|
|
|
|
import androidx.compose.material.icons.filled.Search
|
2026-03-13 16:37:53 +09:00
|
|
|
|
import androidx.compose.runtime.*
|
2026-04-09 13:17:52 +09:00
|
|
|
|
import androidx.compose.ui.Alignment
|
|
|
|
|
|
import androidx.compose.ui.DragData
|
|
|
|
|
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
2026-03-13 16:37:53 +09:00
|
|
|
|
import androidx.compose.ui.Modifier
|
|
|
|
|
|
import androidx.compose.ui.focus.onFocusChanged
|
|
|
|
|
|
import androidx.compose.ui.graphics.Color
|
2026-04-29 17:56:16 +09:00
|
|
|
|
import androidx.compose.ui.input.pointer.PointerEventType
|
2026-04-20 17:09:54 +09:00
|
|
|
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
2026-04-29 17:56:16 +09:00
|
|
|
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
2026-04-20 17:09:54 +09:00
|
|
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
2026-04-29 17:56:16 +09:00
|
|
|
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
|
|
|
|
import androidx.compose.ui.layout.positionInParent
|
2026-04-09 13:17:52 +09:00
|
|
|
|
import androidx.compose.ui.onExternalDrag
|
2026-03-13 16:37:53 +09:00
|
|
|
|
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
|
2026-05-06 15:53:55 +09:00
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
|
|
|
|
import kotlinx.coroutines.Dispatchers
|
2026-04-01 11:11:50 +09:00
|
|
|
|
import kotlinx.coroutines.launch
|
2026-03-13 16:37:53 +09:00
|
|
|
|
import model.ConfigIndex
|
|
|
|
|
|
import model.KisSession
|
2026-04-29 15:32:09 +09:00
|
|
|
|
import network.KisTradeService
|
2026-05-06 15:53:55 +09:00
|
|
|
|
import network.NewsService
|
2026-04-09 13:17:52 +09:00
|
|
|
|
import network.StockUniverseLoader
|
2026-03-26 14:42:39 +09:00
|
|
|
|
import service.AutoTradingManager
|
2026-04-09 13:17:52 +09:00
|
|
|
|
import java.io.File
|
|
|
|
|
|
import java.net.URI
|
2026-03-13 16:37:53 +09:00
|
|
|
|
|
2026-03-20 17:55:27 +09:00
|
|
|
|
@OptIn(ExperimentalMaterialApi::class)
|
2026-03-13 16:37:53 +09:00
|
|
|
|
@Composable
|
|
|
|
|
|
fun TradingDecisionLog() {
|
2026-04-01 11:11:50 +09:00
|
|
|
|
val listState = rememberLazyListState()
|
|
|
|
|
|
val coroutineScope = rememberCoroutineScope()
|
2026-03-20 17:55:27 +09:00
|
|
|
|
var searchQuery by remember { mutableStateOf("") }
|
2026-03-26 15:27:34 +09:00
|
|
|
|
var selectedFilters by remember { mutableStateOf(setOf("전체")) }
|
2026-04-29 15:32:09 +09:00
|
|
|
|
val filterOptions = listOf("전체", "BUY", "SELL", "SETTING","ANALYZER","WATCH","AFTER","NOTICE")//"PASS",,"RETRY""HOLD",
|
2026-03-26 14:42:39 +09:00
|
|
|
|
var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) }
|
2026-04-29 15:32:09 +09:00
|
|
|
|
val tradeConfig by remember {
|
2026-05-06 15:53:55 +09:00
|
|
|
|
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})입니다. 시스템을 가동합니다.")
|
|
|
|
|
|
}
|
2026-04-29 15:32:09 +09:00
|
|
|
|
mutableStateOf(KisSession.tradeConfig)
|
2026-05-06 15:53:55 +09:00
|
|
|
|
|
2026-04-29 15:32:09 +09:00
|
|
|
|
}
|
2026-03-26 14:42:39 +09:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-13 18:02:38 +09:00
|
|
|
|
val scrollState = rememberScrollState()
|
2026-04-29 17:56:16 +09:00
|
|
|
|
val filterScrollState = rememberScrollState()
|
2026-03-20 17:55:27 +09:00
|
|
|
|
|
|
|
|
|
|
// [핵심] 원본 로그에서 필터 조건에 맞는 리스트만 산출
|
|
|
|
|
|
val filteredLogs = TradingLogStore.decisionLogs.filter { log ->
|
2026-03-26 15:27:34 +09:00
|
|
|
|
val matchesType = if (selectedFilters.contains("전체")) {
|
|
|
|
|
|
true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
selectedFilters.contains(log.decision)
|
|
|
|
|
|
}
|
2026-03-20 17:55:27 +09:00
|
|
|
|
val matchesQuery = log.stockName.contains(searchQuery, ignoreCase = true) ||
|
|
|
|
|
|
log.reason.contains(searchQuery, ignoreCase = true)
|
|
|
|
|
|
matchesType && matchesQuery
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
|
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
2026-04-29 15:32:09 +09:00
|
|
|
|
Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White)) {
|
2026-04-01 11:11:50 +09:00
|
|
|
|
Button(
|
|
|
|
|
|
onClick = {
|
|
|
|
|
|
coroutineScope.launch {
|
|
|
|
|
|
// index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0))
|
2026-04-01 13:05:12 +09:00
|
|
|
|
listState.animateScrollToItem(filteredLogs.size - 1)
|
2026-04-01 11:11:50 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
) { Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) }
|
2026-03-13 16:37:53 +09:00
|
|
|
|
|
2026-03-26 14:42:39 +09:00
|
|
|
|
Row(
|
|
|
|
|
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
|
|
|
|
horizontalArrangement = Arrangement.Start
|
|
|
|
|
|
) {
|
|
|
|
|
|
StatusIndicator("주식 분석기",llmAnalyser)
|
|
|
|
|
|
StatusIndicator("뉴스 처리기",llmNews)
|
|
|
|
|
|
StatusIndicator("한투 인증서",tradeToken)
|
|
|
|
|
|
StatusIndicator("실시간 감시",webSocketConnect)
|
|
|
|
|
|
}
|
2026-03-20 17:55:27 +09:00
|
|
|
|
// [추가] 상단 검색 및 필터 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 형태)
|
2026-04-13 18:02:38 +09:00
|
|
|
|
Row(modifier = Modifier
|
|
|
|
|
|
.fillMaxWidth()
|
|
|
|
|
|
.horizontalScroll(scrollState),
|
|
|
|
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
2026-04-29 17:56:16 +09:00
|
|
|
|
verticalAlignment = Alignment.CenterVertically
|
2026-04-20 17:09:54 +09:00
|
|
|
|
) {
|
2026-03-20 17:55:27 +09:00
|
|
|
|
filterOptions.forEach { option ->
|
2026-03-26 15:27:34 +09:00
|
|
|
|
val isSelected = selectedFilters.contains(option)
|
2026-04-20 17:09:54 +09:00
|
|
|
|
FilterChipWithRightClick(
|
|
|
|
|
|
isSelected = isSelected,
|
2026-03-26 15:27:34 +09:00
|
|
|
|
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
|
|
|
|
|
|
},
|
2026-04-20 17:09:54 +09:00
|
|
|
|
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
|
|
|
|
|
|
},
|
2026-04-29 17:56:16 +09:00
|
|
|
|
label = option,
|
|
|
|
|
|
scrollState = scrollState
|
2026-04-20 17:09:54 +09:00
|
|
|
|
)
|
2026-03-20 17:55:27 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Divider(Modifier.padding(bottom = 8.dp))
|
2026-04-09 13:17:52 +09:00
|
|
|
|
CsvDropZone(
|
|
|
|
|
|
onUniverseUpdated = { updatedList ->
|
|
|
|
|
|
// UI 갱신 (필요한 경우)
|
|
|
|
|
|
// currentUniverse = updatedList
|
|
|
|
|
|
|
|
|
|
|
|
// 💡 봇의 실제 작업 큐인 loadedTops 에도 갱신된 데이터를 덮어씌워 줌
|
|
|
|
|
|
AutoTradingManager.loadedTops.clear()
|
|
|
|
|
|
AutoTradingManager.loadedTops.addAll(updatedList)
|
|
|
|
|
|
AutoTradingManager.loadedTops.shuffle() // 섞어주면 편향 분석 방지!
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
2026-03-20 17:55:27 +09:00
|
|
|
|
|
|
|
|
|
|
// [수정] filteredLogs를 사용하여 최신 로그가 위로 오게 표시
|
2026-04-01 11:11:50 +09:00
|
|
|
|
LazyColumn(
|
|
|
|
|
|
state = listState,
|
|
|
|
|
|
reverseLayout = true) {
|
2026-03-20 17:55:27 +09:00
|
|
|
|
items(filteredLogs) { log ->
|
2026-03-13 16:37:53 +09:00
|
|
|
|
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,
|
2026-03-13 17:34:48 +09:00
|
|
|
|
color = when (log.decision) {
|
|
|
|
|
|
"BUY" -> Color.Red
|
2026-03-20 17:55:27 +09:00
|
|
|
|
"SETTING" -> Color(0xFFFFA500)
|
|
|
|
|
|
"SELL" -> Color(0xFF800080)
|
2026-03-26 15:27:34 +09:00
|
|
|
|
"HOLD" -> Color.DarkGray
|
2026-03-26 14:42:39 +09:00
|
|
|
|
"ANALYZER" -> Color.Green
|
2026-03-26 15:27:34 +09:00
|
|
|
|
"PASS" -> Color.Yellow
|
2026-04-02 14:05:14 +09:00
|
|
|
|
"RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열)
|
|
|
|
|
|
"WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시)
|
2026-04-03 17:47:56 +09:00
|
|
|
|
"AFTER" -> Color.Red
|
2026-04-08 14:18:09 +09:00
|
|
|
|
"NOTICE" ->Color(0xFF1E88E5)
|
2026-03-26 15:27:34 +09:00
|
|
|
|
else -> Color.DarkGray
|
2026-03-13 17:34:48 +09:00
|
|
|
|
},
|
2026-03-13 16:37:53 +09:00
|
|
|
|
fontWeight = FontWeight.ExtraBold
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-03-26 15:40:46 +09:00
|
|
|
|
when (log.decision) {
|
|
|
|
|
|
"BUY", "SELL", "HOLD" -> Text("신뢰도: ${log.confidence}%", fontSize = 11.sp)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
|
Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray)
|
2026-04-29 15:32:09 +09:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 15:32:09 +09:00
|
|
|
|
Column(modifier = Modifier.weight(1f).padding(6.dp).fillMaxHeight().background(Color.White)) {
|
2026-03-13 16:37:53 +09:00
|
|
|
|
LazyVerticalGrid(
|
2026-04-10 17:15:31 +09:00
|
|
|
|
columns = GridCells.Fixed(6), // 💡 2와 3의 최소공배수인 6열로 통합!
|
2026-03-13 16:37:53 +09:00
|
|
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
|
|
|
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
2026-04-10 17:15:31 +09:00
|
|
|
|
modifier = Modifier.fillMaxSize().background(Color.White) // fillMaxWidth() 대신 전체 채우기
|
2026-03-13 16:37:53 +09:00
|
|
|
|
) {
|
2026-04-10 17:15:31 +09:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 1️⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
|
|
|
|
|
|
item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지
|
2026-03-13 16:37:53 +09:00
|
|
|
|
Text(
|
|
|
|
|
|
"💰 거래 기본 설정",
|
|
|
|
|
|
style = MaterialTheme.typography.subtitle2,
|
|
|
|
|
|
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-10 17:15:31 +09:00
|
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
|
var defaults = arrayOf(
|
2026-04-10 17:15:31 +09:00
|
|
|
|
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,
|
2026-03-13 16:37:53 +09:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-10 17:15:31 +09:00
|
|
|
|
// 💡 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() ?: "") }
|
2026-03-13 16:37:53 +09:00
|
|
|
|
|
|
|
|
|
|
val saveAction = {
|
|
|
|
|
|
var newValue = localText.toDoubleOrNull() ?: 0.0
|
2026-03-13 17:34:48 +09:00
|
|
|
|
var oldValue = KisSession.config.getValues(configKey)
|
2026-03-13 16:37:53 +09:00
|
|
|
|
if (configKey.label.contains("PROFIT")) {
|
2026-04-10 17:15:31 +09:00
|
|
|
|
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
|
2026-03-13 16:37:53 +09:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-04-10 17:15:31 +09:00
|
|
|
|
onValueChange = { localText = it },
|
2026-03-13 16:37:53 +09:00
|
|
|
|
label = { Text(configKey.label) },
|
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
|
.fillMaxWidth()
|
2026-04-10 17:15:31 +09:00
|
|
|
|
.onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() },
|
|
|
|
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal),
|
|
|
|
|
|
keyboardActions = KeyboardActions(onDone = { saveAction() }),
|
2026-03-13 16:37:53 +09:00
|
|
|
|
singleLine = true
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-02 14:05:14 +09:00
|
|
|
|
|
2026-04-10 17:15:31 +09:00
|
|
|
|
// --- 🛡️ 수익 및 리스크 관리 섹션 ---
|
|
|
|
|
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
|
|
|
|
|
Column {
|
2026-04-29 15:41:20 +09:00
|
|
|
|
Spacer(modifier = Modifier.height(20.dp))
|
2026-04-10 17:15:31 +09:00
|
|
|
|
Divider(color = Color.LightGray, thickness = 1.dp)
|
2026-04-29 15:41:20 +09:00
|
|
|
|
Spacer(modifier = Modifier.height(10.dp))
|
2026-04-10 17:15:31 +09:00
|
|
|
|
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(
|
2026-04-29 15:41:20 +09:00
|
|
|
|
label = "보유 주식 자동 매도 활성화",
|
2026-04-17 18:08:53 +09:00
|
|
|
|
initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) > 0.0,
|
|
|
|
|
|
onCheckedChange = {
|
|
|
|
|
|
KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0)
|
2026-04-20 17:09:54 +09:00
|
|
|
|
},
|
2026-04-29 15:41:20 +09:00
|
|
|
|
helperText = "목표 수익률 ${KisSession.config.SELL_PROFIT} 도달 시 기계적 매도"
|
2026-04-10 17:15:31 +09:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
item(span = { GridItemSpan(3) }) {
|
|
|
|
|
|
SettingSwitchField(
|
|
|
|
|
|
label = "자동 손절 활성화",
|
2026-04-17 18:08:53 +09:00
|
|
|
|
initialChecked = KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0,
|
2026-04-10 17:15:31 +09:00
|
|
|
|
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 {
|
2026-04-29 15:41:20 +09:00
|
|
|
|
Spacer(modifier = Modifier.height(10.dp))
|
2026-04-10 17:15:31 +09:00
|
|
|
|
Divider(color = Color.LightGray, thickness = 1.dp)
|
2026-04-29 15:41:20 +09:00
|
|
|
|
Spacer(modifier = Modifier.height(20.dp))
|
2026-04-10 17:15:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 2️⃣ 두 번째 섹션: 3열 배치 구간 (span = 2)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지
|
2026-03-13 16:37:53 +09:00
|
|
|
|
Text(
|
2026-04-10 17:15:31 +09:00
|
|
|
|
"💰 매수 정책 및 기대 수익률",
|
2026-03-13 16:37:53 +09:00
|
|
|
|
style = MaterialTheme.typography.subtitle2,
|
|
|
|
|
|
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-10 17:15:31 +09:00
|
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
|
var defaults2 = arrayOf(
|
2026-04-10 17:15:31 +09:00
|
|
|
|
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),
|
2026-03-13 16:37:53 +09:00
|
|
|
|
)
|
2026-04-10 17:15:31 +09:00
|
|
|
|
|
2026-03-13 16:37:53 +09:00
|
|
|
|
for (items in defaults2) {
|
2026-04-10 17:15:31 +09:00
|
|
|
|
val common = findLongestCommonSubstring(items.first().label, items.last().label)
|
|
|
|
|
|
|
|
|
|
|
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
2026-03-13 16:37:53 +09:00
|
|
|
|
Text(
|
|
|
|
|
|
common,
|
|
|
|
|
|
style = MaterialTheme.typography.body1,
|
|
|
|
|
|
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 17:15:31 +09:00
|
|
|
|
// 💡 items에 span을 주어 2칸씩 차지하게 만듦 (결과적으로 3열 배치)
|
|
|
|
|
|
items(items.size, span = { GridItemSpan(2) }) { index ->
|
2026-03-13 16:37:53 +09:00
|
|
|
|
val configKey = items.get(index)
|
2026-04-10 17:15:31 +09:00
|
|
|
|
var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") }
|
|
|
|
|
|
var labelText by remember(configKey) { mutableStateOf("") }
|
2026-03-13 16:37:53 +09:00
|
|
|
|
|
|
|
|
|
|
val saveAction = {
|
2026-03-13 17:34:48 +09:00
|
|
|
|
var oldValue = KisSession.config.getValues(configKey)
|
2026-03-13 16:37:53 +09:00
|
|
|
|
var newValue = localText.toDoubleOrNull() ?: 0.0
|
|
|
|
|
|
KisSession.config.setValues(configKey, newValue)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 17:15:31 +09:00
|
|
|
|
// labelText 업데이트 로직 (기존과 동일)
|
2026-03-13 16:37:53 +09:00
|
|
|
|
labelText = if (configKey.name.contains("PROFIT")) {
|
2026-04-10 17:15:31 +09:00
|
|
|
|
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)}원"
|
2026-03-13 16:37:53 +09:00
|
|
|
|
} else {
|
2026-04-10 17:15:31 +09:00
|
|
|
|
getRemaining(configKey.label, common) + ": -${localText} 호가 매수"
|
2026-03-13 16:37:53 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
|
value = localText,
|
2026-04-10 17:15:31 +09:00
|
|
|
|
onValueChange = { localText = it },
|
2026-03-13 16:37:53 +09:00
|
|
|
|
label = { Text(labelText) },
|
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
|
.fillMaxWidth()
|
2026-04-10 17:15:31 +09:00
|
|
|
|
.onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() },
|
|
|
|
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal),
|
|
|
|
|
|
keyboardActions = KeyboardActions(onDone = { saveAction() }),
|
2026-03-13 16:37:53 +09:00
|
|
|
|
singleLine = true
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 15:32:09 +09:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
// 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() }
|
|
|
|
|
|
)
|
2026-05-06 16:43:49 +09:00
|
|
|
|
|
|
|
|
|
|
SettingInputField(
|
|
|
|
|
|
label = "특정 메시지를 수신하려면 텔레그램 아뒤 입력",
|
|
|
|
|
|
initialValue = (tradeConfig.tlg_id).toString(),
|
|
|
|
|
|
onSave = {
|
|
|
|
|
|
tradeConfig.tlg_id = it
|
|
|
|
|
|
KisSession.saveTradeConfig()
|
|
|
|
|
|
},
|
|
|
|
|
|
helperText = "본인의 텔레그램 아뒤"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-29 15:32:09 +09:00
|
|
|
|
}
|
2026-03-13 16:37:53 +09:00
|
|
|
|
}
|
2026-03-26 14:42:39 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
|
fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? = null) {
|
|
|
|
|
|
Row(
|
2026-04-20 17:09:54 +09:00
|
|
|
|
verticalAlignment = Alignment.CenterVertically,
|
2026-03-26 14:42:39 +09:00
|
|
|
|
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)
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-07 17:32:21 +09:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-04-09 13:17:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-10 17:15:31 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(),
|
2026-04-20 17:09:54 +09:00
|
|
|
|
verticalAlignment = Alignment.CenterVertically
|
2026-04-10 17:15:31 +09:00
|
|
|
|
) {
|
|
|
|
|
|
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)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 17:56:16 +09:00
|
|
|
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
|
2026-04-20 17:09:54 +09:00
|
|
|
|
@Composable
|
|
|
|
|
|
fun FilterChipWithRightClick(
|
|
|
|
|
|
label: String,
|
|
|
|
|
|
isSelected: Boolean,
|
|
|
|
|
|
onClick: () -> Unit,
|
2026-04-29 17:56:16 +09:00
|
|
|
|
onClear: () -> Unit,
|
|
|
|
|
|
scrollState: ScrollState // 스크롤 제어를 위해 추가
|
2026-04-20 17:09:54 +09:00
|
|
|
|
) {
|
|
|
|
|
|
var showMenu by remember { mutableStateOf(false) }
|
2026-04-29 17:56:16 +09:00
|
|
|
|
val coroutineScope = rememberCoroutineScope()
|
|
|
|
|
|
|
|
|
|
|
|
var componentOffset by remember { mutableStateOf(0f) }
|
|
|
|
|
|
var componentWidth by remember { mutableStateOf(0) } // 칩 너비 추가
|
2026-04-20 17:09:54 +09:00
|
|
|
|
|
2026-04-29 17:56:16 +09:00
|
|
|
|
|
|
|
|
|
|
Box(
|
|
|
|
|
|
modifier = Modifier.onGloballyPositioned { coordinates ->
|
|
|
|
|
|
componentOffset = coordinates.positionInParent().x
|
|
|
|
|
|
componentWidth = coordinates.size.width // 칩의 실제 너비 저장
|
|
|
|
|
|
}
|
|
|
|
|
|
) {
|
2026-04-20 17:09:54 +09:00
|
|
|
|
Surface(
|
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
|
.padding(end = 6.dp)
|
|
|
|
|
|
.pointerInput(label) {
|
2026-04-29 17:56:16 +09:00
|
|
|
|
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)
|
|
|
|
|
|
)
|
2026-04-20 17:09:54 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 17:56:16 +09:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 마우스 우클릭 별도 감지
|
|
|
|
|
|
.onPointerEvent(PointerEventType.Press) { event ->
|
|
|
|
|
|
if (event.buttons.isSecondaryPressed) showMenu = true
|
2026-04-20 17:09:54 +09:00
|
|
|
|
},
|
2026-04-29 17:56:16 +09:00
|
|
|
|
shape = RoundedCornerShape(8.dp),
|
2026-04-20 17:09:54 +09:00
|
|
|
|
color = if (isSelected) Color(0xFF0E62CF) else Color(0xFFF0F2F5),
|
|
|
|
|
|
border = BorderStroke(1.dp, if (isSelected) Color(0xFF0E62CF) else Color.LightGray)
|
|
|
|
|
|
) {
|
|
|
|
|
|
Text(
|
|
|
|
|
|
text = label,
|
2026-04-29 17:56:16 +09:00
|
|
|
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
2026-04-20 17:09:54 +09:00
|
|
|
|
fontSize = 11.sp,
|
2026-04-29 17:56:16 +09:00
|
|
|
|
color = if (isSelected) Color.White else Color.Black
|
2026-04-20 17:09:54 +09:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 17:56:16 +09:00
|
|
|
|
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
2026-04-20 17:09:54 +09:00
|
|
|
|
DropdownMenuItem(onClick = {
|
2026-04-29 17:56:16 +09:00
|
|
|
|
onClear()
|
2026-04-20 17:09:54 +09:00
|
|
|
|
showMenu = false
|
|
|
|
|
|
}) {
|
2026-04-29 17:56:16 +09:00
|
|
|
|
Text(if (label == "전체") "전체 로그 초기화" else "'$label' 삭제", color = Color.Red)
|
2026-04-20 17:09:54 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 15:32:09 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 시:분 입력을 위한 전용 컴포저블
|
|
|
|
|
|
*/
|
|
|
|
|
|
@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)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-20 17:09:54 +09:00
|
|
|
|
}
|