// 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.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import model.ExecutionData import model.KisSession import model.StockBasicInfo import network.KisTradeService import network.KisWebSocketManager import service.AutoTradingManager @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) } // 단순 종목 선택 시 DisposableEffect(Unit) { // 1. 화면 진입 시: 자동 발굴 루프 시작 // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess -> if (isSuccess && decision != null) { selectedStockCode = decision.stockCode selectedStockName = decision.stockName isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거 completeTradingDecision = decision } } // 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리) onDispose { AutoTradingManager.stopDiscovery() } } // 중앙 관리용 상태들 var refreshTrigger by remember { mutableStateOf(0) } // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 val executionCache = remember { mutableMapOf() } val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 suspend fun syncAndExecute(orderNo: String) { // 이미 다른 코루틴에서 이 주문을 처리 중이라면 즉시 종료 (중복 방지) if (processingIds.contains(orderNo)) return processingIds.add(orderNo) try { // DB 아이템과 체결 데이터(캐시)를 모두 가져옴 val dbItem = DatabaseFactory.findByOrderNo(orderNo) val execData = executionCache[orderNo] // 둘 다 존재할 때만 로직 실행 if (dbItem != null && execData != null && execData.isFilled) { if (dbItem.status == TradeStatus.PENDING_BUY) { println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} (${orderNo})") val sellPrice = dbItem.targetPrice.toLong().toString() tradeService.postOrder( stockCode = dbItem.code, qty = dbItem.quantity.toString(), price = sellPrice, isBuy = false ).onSuccess { newSellOrderNo -> DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) // 처리가 완료된 체결 데이터는 캐시에서 삭제 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 { println("$orderNo, $code, $price, $qty, $isBuy") 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.125f).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( stockCode = selectedStockCode, stockName = selectedStockName, holdingQuantity = selectedStockQuantity, isDomestic = isDomestic, tradeService = tradeService, wsManager = wsManager, onOrderSaved = { orderNo -> scope.launch { syncAndExecute(orderNo) // 매칭 시도 } }, completeTradingDecision ) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("분석할 종목을 선택하세요", color = Color.Gray) } } } VerticalDivider() Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) { AiAnalysisView( stockCode = selectedStockCode, stockName = selectedStockName, currentPrice = wsManager.currentPrice.value, trades = wsManager.tradeLogs, tradingDecisionCallback = { decision,bool -> if (bool && decision != null && KisSession.config.isSimulation) { completeTradingDecision = decision } } ) } VerticalDivider() Column(modifier = Modifier.weight(0.125f).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.125f).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)) }