atrade/src/main/kotlin/ui/DashboardScreen.kt
2026-02-24 13:14:11 +09:00

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