2026-01-13 16:04:25 +09:00
|
|
|
// src/main/kotlin/ui/DashboardScreen.kt
|
2026-01-10 18:16:50 +09:00
|
|
|
package ui
|
|
|
|
|
|
2026-01-14 15:42:26 +09:00
|
|
|
import AutoTradeItem
|
2026-01-23 17:05:09 +09:00
|
|
|
import TradingDecision
|
2026-01-10 18:16:50 +09:00
|
|
|
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
|
2026-01-14 15:42:26 +09:00
|
|
|
import kotlinx.coroutines.launch
|
2026-02-04 14:52:09 +09:00
|
|
|
import model.CandleData
|
2026-01-21 11:49:30 +09:00
|
|
|
import model.ExecutionData
|
2026-01-13 16:04:25 +09:00
|
|
|
import model.KisSession
|
2026-01-19 17:09:37 +09:00
|
|
|
import model.StockBasicInfo
|
2026-02-04 14:52:09 +09:00
|
|
|
import model.feesAndTaxRate
|
|
|
|
|
import model.minimumNetProfit
|
2026-01-10 18:16:50 +09:00
|
|
|
import network.KisTradeService
|
|
|
|
|
import network.KisWebSocketManager
|
2026-02-03 18:07:18 +09:00
|
|
|
import service.AutoTradingManager
|
2026-02-04 14:52:09 +09:00
|
|
|
import service.TechnicalAnalyzer
|
|
|
|
|
import util.MarketUtil
|
|
|
|
|
import kotlin.collections.mutableListOf
|
2026-01-10 18:16:50 +09:00
|
|
|
|
|
|
|
|
@Composable
|
2026-01-13 16:04:25 +09:00
|
|
|
fun DashboardScreen() {
|
2026-01-22 16:21:18 +09:00
|
|
|
val tradeService = remember { KisTradeService }
|
2026-01-13 16:04:25 +09:00
|
|
|
val wsManager = remember { KisWebSocketManager() }
|
2026-01-14 15:42:26 +09:00
|
|
|
val scope = rememberCoroutineScope()
|
2026-01-10 18:16:50 +09:00
|
|
|
var selectedStockCode by remember { mutableStateOf("") }
|
|
|
|
|
var selectedStockName by remember { mutableStateOf("") }
|
2026-01-13 16:04:25 +09:00
|
|
|
var isDomestic by remember { mutableStateOf(true) }
|
2026-01-19 17:09:37 +09:00
|
|
|
var selectedStockQuantity by remember { mutableStateOf("0") }
|
|
|
|
|
|
|
|
|
|
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
|
|
|
|
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
2026-01-23 17:05:09 +09:00
|
|
|
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
|
|
|
|
|
|
2026-02-04 14:52:09 +09:00
|
|
|
|
|
|
|
|
var min30 by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
|
|
|
|
var daySummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
|
|
|
|
var weekSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
|
|
|
|
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
|
|
|
|
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
|
|
|
|
|
2026-02-03 18:07:18 +09:00
|
|
|
DisposableEffect(Unit) {
|
|
|
|
|
// 1. 화면 진입 시: 자동 발굴 루프 시작
|
|
|
|
|
// AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여
|
|
|
|
|
// IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다.
|
|
|
|
|
AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess ->
|
2026-02-04 14:52:09 +09:00
|
|
|
if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
|
|
|
|
|
decision?.stockCode?.let { stockCode ->
|
|
|
|
|
decision?.stockName?.let { stockName ->
|
|
|
|
|
selectedStockCode = stockCode
|
|
|
|
|
selectedStockName = stockName
|
|
|
|
|
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-03 18:07:18 +09:00
|
|
|
|
2026-02-04 14:52:09 +09:00
|
|
|
}else if (isSuccess && decision != null) {
|
|
|
|
|
if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) {
|
|
|
|
|
selectedStockCode = decision.stockCode
|
|
|
|
|
selectedStockName = decision.stockName
|
|
|
|
|
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
|
|
|
|
|
}
|
2026-02-03 18:07:18 +09:00
|
|
|
// 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
|
|
|
|
|
completeTradingDecision = decision
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리)
|
|
|
|
|
onDispose {
|
|
|
|
|
AutoTradingManager.stopDiscovery()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-19 17:09:37 +09:00
|
|
|
|
2026-01-21 11:49:30 +09:00
|
|
|
// 중앙 관리용 상태들
|
|
|
|
|
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 {
|
|
|
|
|
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
|
|
|
|
val execData = executionCache[orderNo]
|
2026-01-19 17:09:37 +09:00
|
|
|
|
2026-01-21 11:49:30 +09:00
|
|
|
if (dbItem != null && execData != null && execData.isFilled) {
|
|
|
|
|
if (dbItem.status == TradeStatus.PENDING_BUY) {
|
2026-02-04 14:52:09 +09:00
|
|
|
// 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)}% 적용)")
|
2026-01-21 11:49:30 +09:00
|
|
|
|
|
|
|
|
tradeService.postOrder(
|
|
|
|
|
stockCode = dbItem.code,
|
|
|
|
|
qty = dbItem.quantity.toString(),
|
2026-02-04 14:52:09 +09:00
|
|
|
price = finalTargetPrice.toLong().toString(),
|
2026-01-21 11:49:30 +09:00
|
|
|
isBuy = false
|
|
|
|
|
).onSuccess { newSellOrderNo ->
|
2026-02-04 14:52:09 +09:00
|
|
|
// 익절가 업데이트 및 상태 변경
|
2026-01-21 11:49:30 +09:00
|
|
|
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
|
2026-02-04 14:52:09 +09:00
|
|
|
// (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
|
|
|
|
|
|
2026-01-21 11:49:30 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
|
|
|
|
|
LaunchedEffect(Unit) {
|
2026-01-14 15:42:26 +09:00
|
|
|
// 1. 웹소켓 연결
|
2026-01-13 16:04:25 +09:00
|
|
|
wsManager.connect()
|
2026-01-14 15:42:26 +09:00
|
|
|
|
2026-01-19 17:09:37 +09:00
|
|
|
// 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)
|
|
|
|
|
|
2026-01-14 15:42:26 +09:00
|
|
|
refreshTrigger++
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 17:09:37 +09:00
|
|
|
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
2026-01-21 11:49:30 +09:00
|
|
|
wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
|
2026-01-14 15:42:26 +09:00
|
|
|
scope.launch {
|
2026-01-21 11:49:30 +09:00
|
|
|
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
|
|
|
|
|
executionCache[orderNo] = exec
|
|
|
|
|
syncAndExecute(orderNo)
|
2026-01-14 15:42:26 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
2026-01-13 16:04:25 +09:00
|
|
|
// [좌측 25%] 내 자산 및 통합 잔고
|
2026-01-22 16:21:18 +09:00
|
|
|
Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
|
2026-01-19 17:09:37 +09:00
|
|
|
BalanceSection(tradeService,
|
|
|
|
|
onRefresh = { refreshTrigger++ },
|
|
|
|
|
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
|
2026-01-10 18:16:50 +09:00
|
|
|
selectedStockCode = code
|
|
|
|
|
selectedStockName = name
|
2026-01-13 16:04:25 +09:00
|
|
|
isDomestic = isDom
|
2026-01-19 17:09:37 +09:00
|
|
|
selectedStockQuantity = qty
|
2026-01-13 16:04:25 +09:00
|
|
|
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VerticalDivider()
|
|
|
|
|
|
2026-01-13 16:04:25 +09:00
|
|
|
// [중앙 45%] 실시간 정보 및 주문
|
2026-01-22 16:21:18 +09:00
|
|
|
Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
|
2026-01-10 18:16:50 +09:00
|
|
|
if (selectedStockCode.isNotEmpty()) {
|
2026-01-13 16:04:25 +09:00
|
|
|
StockDetailSection(
|
2026-02-04 14:52:09 +09:00
|
|
|
min30 = min30,
|
|
|
|
|
daySummary = daySummary,
|
|
|
|
|
monthSummary = monthSummary,
|
|
|
|
|
weekSummary = weekSummary,
|
|
|
|
|
yearSummary = yearSummary,
|
2026-01-13 16:04:25 +09:00
|
|
|
stockCode = selectedStockCode,
|
|
|
|
|
stockName = selectedStockName,
|
2026-01-19 17:09:37 +09:00
|
|
|
holdingQuantity = selectedStockQuantity,
|
2026-01-13 16:04:25 +09:00
|
|
|
isDomestic = isDomestic,
|
|
|
|
|
tradeService = tradeService,
|
2026-01-21 11:49:30 +09:00
|
|
|
wsManager = wsManager,
|
|
|
|
|
onOrderSaved = { orderNo ->
|
|
|
|
|
scope.launch {
|
|
|
|
|
syncAndExecute(orderNo) // 매칭 시도
|
|
|
|
|
}
|
2026-01-23 17:05:09 +09:00
|
|
|
},
|
2026-02-04 14:52:09 +09:00
|
|
|
completeTradingDecision = completeTradingDecision,
|
2026-01-13 16:04:25 +09:00
|
|
|
)
|
2026-01-10 18:16:50 +09:00
|
|
|
} else {
|
|
|
|
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
2026-01-13 16:04:25 +09:00
|
|
|
Text("분석할 종목을 선택하세요", color = Color.Gray)
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VerticalDivider()
|
2026-01-22 16:21:18 +09:00
|
|
|
Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) {
|
|
|
|
|
AiAnalysisView(
|
2026-02-04 14:52:09 +09:00
|
|
|
technicalAnalyzer = TechnicalAnalyzer().apply {
|
|
|
|
|
this.min30 = min30
|
|
|
|
|
this.daily = daySummary
|
|
|
|
|
this.weekly = weekSummary
|
|
|
|
|
this.monthly = monthSummary
|
|
|
|
|
this.weekly = weekSummary
|
|
|
|
|
},
|
2026-01-22 16:21:18 +09:00
|
|
|
stockCode = selectedStockCode,
|
|
|
|
|
stockName = selectedStockName,
|
|
|
|
|
currentPrice = wsManager.currentPrice.value,
|
2026-01-23 17:05:09 +09:00
|
|
|
trades = wsManager.tradeLogs,
|
|
|
|
|
tradingDecisionCallback = { decision,bool ->
|
|
|
|
|
if (bool && decision != null && KisSession.config.isSimulation) {
|
|
|
|
|
completeTradingDecision = decision
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-22 16:21:18 +09:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
VerticalDivider()
|
|
|
|
|
Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
|
2026-01-14 15:42:26 +09:00
|
|
|
AutoTradeSection(
|
2026-01-19 17:09:37 +09:00
|
|
|
isDomestic = isDomestic,
|
2026-01-14 15:42:26 +09:00
|
|
|
tradeService = tradeService,
|
|
|
|
|
onRefresh = { refreshTrigger++ },
|
2026-01-19 17:09:37 +09:00
|
|
|
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
|
|
|
|
|
})
|
2026-01-14 15:42:26 +09:00
|
|
|
}
|
|
|
|
|
VerticalDivider()
|
2026-01-13 16:04:25 +09:00
|
|
|
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
2026-01-22 16:21:18 +09:00
|
|
|
Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
|
2026-01-13 16:04:25 +09:00
|
|
|
MarketSection(tradeService) { code, name, isDom ->
|
2026-01-19 17:09:37 +09:00
|
|
|
val info = StockBasicInfo(
|
|
|
|
|
code = code,
|
|
|
|
|
name = name,
|
|
|
|
|
isDomestic = isDom
|
|
|
|
|
)
|
|
|
|
|
selectedStockInfo = info
|
2026-01-10 18:16:50 +09:00
|
|
|
selectedStockCode = code
|
|
|
|
|
selectedStockName = name
|
2026-01-13 16:04:25 +09:00
|
|
|
isDomestic = isDom
|
|
|
|
|
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-23 17:05:09 +09:00
|
|
|
|
|
|
|
|
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 17:05:09 +09:00
|
|
|
|
|
|
|
|
|
2026-01-10 18:16:50 +09:00
|
|
|
@Composable
|
2026-01-13 16:04:25 +09:00
|
|
|
fun VerticalDivider() {
|
|
|
|
|
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
|
2026-01-10 18:16:50 +09:00
|
|
|
}
|