// 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData import model.ExecutionData import model.KisSession import model.StockBasicInfo import model.feesAndTaxRate import model.minimumNetProfit 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 } } } DisposableEffect(Unit) { // 1. 화면 진입 시: 자동 발굴 루프 시작 // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. AutoTradingManager.startAutoDiscoveryLoop(tradeService,callback) // 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리) 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 = minimumNetProfit + feesAndTaxRate // 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.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( 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.2f).fillMaxHeight().padding(8.dp)) { AiAnalysisView( technicalAnalyzer = TechnicalAnalyzer().apply { this.min30 = min30 this.daily = daySummary this.weekly = weekSummary this.monthly = monthSummary this.weekly = weekSummary }, 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)) }