atrade/src/main/kotlin/ui/DashboardScreen.kt
2026-02-03 18:07:18 +09:00

238 lines
9.9 KiB
Kotlin

// 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<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(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<String, ExecutionData>() }
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
// [중앙 관리 함수] 체결 정보와 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))
}