// src/main/kotlin/ui/DashboardScreen.kt package ui import AutoTradeItem import TradingDecision import androidx.compose.foundation.background import androidx.compose.foundation.layout.* 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.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData import model.ConfigIndex import model.ExecutionData import model.KisSession import model.StockBasicInfo import network.KisTradeService import network.KisWebSocketManager import service.AutoTradingManager import service.TechnicalAnalyzer import service.TradingDecisionCallback import util.MarketUtil import kotlin.collections.mutableListOf @Composable fun DashboardScreen() { val tradeService = remember { KisTradeService } val wsManager = remember { KisWebSocketManager() } val scope = rememberCoroutineScope() var selectedStockCode by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") } var isDomestic by remember { mutableStateOf(true) } var selectedStockQuantity by remember { mutableStateOf("0") } var selectedItem by remember { mutableStateOf(null) } // 감시/미체결 아이템 선택 시 var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 var completeTradingDecision by remember { mutableStateOf(null) } // 단순 종목 선택 시 var min30 by remember { mutableStateOf>(mutableListOf()) } var daySummary by remember { mutableStateOf>(mutableListOf()) } var weekSummary by remember { mutableStateOf>(mutableListOf()) } var monthSummary by remember { mutableStateOf>(mutableListOf()) } var yearSummary by remember { mutableStateOf>(mutableListOf()) } fun setupAutoTradingWatchdog(tradeService: KisTradeService, callback: TradingDecisionCallback) { CoroutineScope(Dispatchers.Default).launch { // while (true) { // delay(60000) // 1분마다 체크 AutoTradingManager.checkAndRestart(tradeService, callback) // } } } var callback = object : TradingDecisionCallback { override fun invoke(decision: TradingDecision?, isSuccess: Boolean) { if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) { decision?.stockCode?.let { stockCode -> decision?.stockName?.let { stockName -> selectedStockCode = stockCode selectedStockName = stockName isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 } } }else if (isSuccess && decision != null) { if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) { selectedStockCode = decision.stockCode selectedStockName = decision.stockName isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 } // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거 completeTradingDecision = decision } } } LaunchedEffect(Unit) { // 화면이 완전히 그려지고 안정화될 때까지 1초 대기 delay(1000) AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback) } // 리소스 정리는 여전히 DisposableEffect에서 수행 DisposableEffect(Unit) { onDispose { AutoTradingManager.stopDiscovery() } } // 중앙 관리용 상태들 var refreshTrigger by remember { mutableStateOf(0) } // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 val executionCache = remember { mutableMapOf() } val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 LaunchedEffect(refreshTrigger) { // setupAutoTradingWatchdog(tradeService,callback) } suspend fun syncAndExecute(orderNo: String) { if (processingIds.contains(orderNo)) return processingIds.add(orderNo) try { val dbItem = DatabaseFactory.findByOrderNo(orderNo) val execData = executionCache[orderNo] if (dbItem != null && execData != null && execData.isFilled) { if (dbItem.status == TradeStatus.PENDING_BUY) { // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") tradeService.postOrder( stockCode = dbItem.code, qty = dbItem.quantity.toString(), price = finalTargetPrice.toLong().toString(), isBuy = false ).onSuccess { newSellOrderNo -> // 익절가 업데이트 및 상태 변경 DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능 executionCache.remove(orderNo) refreshTrigger++ }.onFailure { println("❌ 익절 주문 실패: ${it.message}") } } else if (dbItem.status == TradeStatus.SELLING) { println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) executionCache.remove(orderNo) refreshTrigger++ } } } finally { processingIds.remove(orderNo) } } LaunchedEffect(Unit) { // 1. 웹소켓 연결 wsManager.connect() // 2. [기동 시 동기화 시나리오] scope.launch { // (1) 서버 미체결 내역 로드 val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()) val serverOrderNos = serverOrders.map { it.ord_no } // (2) DB 상태 대조 및 EXPIRED 전환 DatabaseFactory.syncWithServer(serverOrderNos) // (3) 활성 감시 종목 구독 재개 val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) val monitoringCodes = monitoringTrades.map { it.code }.toSet() wsManager.updateSubscriptions(monitoringCodes) refreshTrigger++ } // 3. 실시간 체결 통보 핸들러 (주문번호 중심) wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy -> scope.launch { val exec = ExecutionData(orderNo, code, price, qty, isBuy) executionCache[orderNo] = exec syncAndExecute(orderNo) } } } Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { // [좌측 25%] 내 자산 및 통합 잔고 Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { BalanceSection(tradeService, onRefresh = { refreshTrigger++ }, refreshTrigger = refreshTrigger) { code, name, isDom,qty -> selectedStockCode = code selectedStockName = name isDomestic = isDom selectedStockQuantity = qty println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") } } VerticalDivider() // [중앙 45%] 실시간 정보 및 주문 Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) { if (selectedStockCode.isNotEmpty()) { StockDetailSection( min30 = min30, daySummary = daySummary, monthSummary = monthSummary, weekSummary = weekSummary, yearSummary = yearSummary, stockCode = selectedStockCode, stockName = selectedStockName, holdingQuantity = selectedStockQuantity, isDomestic = isDomestic, tradeService = tradeService, wsManager = wsManager, onOrderSaved = { orderNo -> scope.launch { syncAndExecute(orderNo) // 매칭 시도 } }, completeTradingDecision = completeTradingDecision, ) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("분석할 종목을 선택하세요", color = Color.Gray) } } } VerticalDivider() Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) { AiAnalysisView( technicalAnalyzer = TechnicalAnalyzer().apply { this.min30 = min30 this.daily = daySummary this.weekly = weekSummary this.monthly = monthSummary this.weekly = weekSummary }, stockCode = selectedStockCode, stockName = selectedStockName, currentPrice = "0", trades = wsManager.tradeLogs, tradingDecisionCallback = { decision,bool -> if (bool && decision != null && KisSession.config.isSimulation) { completeTradingDecision = decision } } ) Spacer(modifier = Modifier.height(16.dp)) Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp)) LazyVerticalGrid( columns = GridCells.Fixed(2), // 2열 병렬 배치 horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().weight(0.3f) ) { item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 Text( "💰 거래 기본 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(top = 16.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.MAX_COUNT_INDEX, ) items(defaults.size) { index -> val configKey = defaults.get(index) // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } // 저장 로직을 공통 함수로 분리 val saveAction = { var newValue = localText.toDoubleOrNull() ?: 0.0 if (configKey.label.contains("PROFIT")) { newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) } KisSession.config.setValues(configKey, newValue) DatabaseFactory.saveConfig(KisSession.config) println("💾 저장됨: ${configKey.label} = $newValue") } var text = if (configKey.label.contains("PROFIT")) { "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" } else { localText } OutlinedTextField( value = text, onValueChange = { localText = it }, // 화면에는 즉시 반영 label = { Text(configKey.label) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> // 2. 포커스를 잃었을 때 저장 if (!focusState.isFocused) { saveAction() } }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal ), keyboardActions = KeyboardActions( // 3. 엔터(Done) 키를 눌렀을 때 저장 onDone = { saveAction() } ), singleLine = true ) } item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 Text( "💰매수 정책 및 기대 수익률", style = MaterialTheme.typography.h6, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) ) } var defaults2 = arrayOf( arrayOf(ConfigIndex.GRADE_5_BUY, ConfigIndex.GRADE_5_PROFIT,), arrayOf(ConfigIndex.GRADE_4_BUY, ConfigIndex.GRADE_4_PROFIT,), arrayOf(ConfigIndex.GRADE_3_BUY, ConfigIndex.GRADE_3_PROFIT,), arrayOf(ConfigIndex.GRADE_2_BUY, ConfigIndex.GRADE_2_PROFIT,), arrayOf(ConfigIndex.GRADE_1_BUY, ConfigIndex.GRADE_1_PROFIT,), ) for (items in defaults2) { val common = findLongestCommonSubstring(items.first().label,items.last().label) item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 Text( common, style = MaterialTheme.typography.h6, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) ) } items(items.size) { index -> val configKey = items.get(index) // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } var labelText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } val saveAction = { var newValue = localText.toDoubleOrNull() ?: 0.0 // KisSession.config.setValues(configKey, newValue) DatabaseFactory.saveConfig(KisSession.config) println("💾 저장됨: ${configKey.label} = $newValue") 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 { getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" } } labelText = if (configKey.name.contains("PROFIT")) { getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( ConfigIndex.TAX_INDEX)} " } else { getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" } OutlinedTextField( value = localText, onValueChange = { localText = it }, // 화면에는 즉시 반영 label = { Text(labelText) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> // 2. 포커스를 잃었을 때 저장 if (!focusState.isFocused) { saveAction() } }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal ), keyboardActions = KeyboardActions( // 3. 엔터(Done) 키를 눌렀을 때 저장 onDone = { saveAction() } ), singleLine = true ) } } } } VerticalDivider() Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { AutoTradeSection( isDomestic = isDomestic, tradeService = tradeService, onRefresh = { refreshTrigger++ }, refreshTrigger = refreshTrigger , // 트리거 전달 onItemCancel = { item -> scope.launch { tradeService.cancelOrder(item.orderNo,item.code).onSuccess { refreshTrigger++ } } }, onItemSelect = { item -> selectedStockCode = item.code selectedStockName = item.name isDomestic = item.isDomestic }) } VerticalDivider() // [우측 30%] 시장 추천 TOP 20 (실전 데이터) Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { MarketSection(tradeService) { code, name, isDom -> val info = StockBasicInfo( code = code, name = name, isDomestic = isDom ) selectedStockInfo = info selectedStockCode = code selectedStockName = name isDomestic = isDom println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") } } } } @Composable fun VerticalDivider() { Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) } 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() }