From 535989b8aeea3a69a7df93ba1db8203fe32423f9 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Thu, 19 Feb 2026 16:20:15 +0900 Subject: [PATCH] ... --- src/main/kotlin/network/KisTradeService.kt | 8 ++ src/main/kotlin/service/AutoTradingManager.kt | 60 ++++----- src/main/kotlin/ui/DashboardScreen.kt | 115 +++++++++--------- 3 files changed, 100 insertions(+), 83 deletions(-) diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 0eff26e..b0e12d9 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -12,6 +12,7 @@ import io.ktor.client.request.* import io.ktor.client.statement.bodyAsText import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.utils.io.CancellationException import kotlinx.serialization.json.Json import model.CandleData import model.RankingResponse @@ -22,6 +23,7 @@ import model.StockBalanceResponse import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -30,6 +32,7 @@ import model.* import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import kotlin.coroutines.coroutineContext object KisTradeService { private val client = HttpClient(CIO) { @@ -525,6 +528,7 @@ object KisTradeService { try { do { + if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가] println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)") val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { header("authorization", "Bearer ${config.tradeToken}") @@ -582,6 +586,10 @@ object KisTradeService { } } catch (e: Exception) { + if (e is CancellationException) { + println("ℹ️ [잔고조회] 사용자가 화면을 벗어나 조회를 중단합니다.") + throw e + } println("💥 [Fatal Error] 잔고 조회 중 예외 발생: ${e.message}") e.printStackTrace() return Result.failure(e) diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 3f31c52..5ca59f7 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -19,6 +19,7 @@ import model.ConfigIndex import model.KisSession import model.RankingStock import model.RankingType +import model.UnifiedBalance import network.DartCodeManager import network.FinancialMapper import network.FinancialStatement @@ -78,13 +79,14 @@ object AutoTradingManager { } - suspend fun resumePendingSellOrders(tradeService: KisTradeService) { + suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. + println("resumePendingSellOrders") val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED)) - + println("pendingSells >>> ${pendingSells.size}") pendingSells.forEach { item -> // 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치) - val balance = tradeService.fetchIntegratedBalance().getOrNull() +// val balance = tradeService.fetchIntegratedBalance().getOrNull() val holding = balance?.holdings?.find { it.code == item.code } if (holding != null && holding.quantity.toInt() > 0) { @@ -123,14 +125,16 @@ object AutoTradingManager { println("⏱️ [Cycle Start] ${LocalTime.now()}") // [프로세스 1] 장 마감 및 잔고 체크 - val now = LocalTime.now(ZoneId.of("Asia/Seoul")) - //&& now.isBefore(LocalTime.of(15, 30)) - if (now.isAfter(LocalTime.of(15, 30)) ) { -// executeClosingLiquidation(tradeService) - return@withTimeout - } +// val now = LocalTime.now(ZoneId.of("Asia/Seoul")) +// //&& now.isBefore(LocalTime.of(15, 30)) +// if (now.isAfter(LocalTime.of(15, 30)) ) { +//// executeClosingLiquidation(tradeService) +// return@withTimeout +// } val balance = tradeService.fetchIntegratedBalance().getOrNull() + + balance?.let { resumePendingSellOrders(tradeService,it) } val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } @@ -295,25 +299,25 @@ object AutoTradingManager { - private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { - val activeTrades = DatabaseFactory.findAllMonitoringTrades() - val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() - val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() - - activeTrades.forEach { trade -> - try { - if (!realHoldings.containsKey(trade.code)) { - DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED) - return@forEach - } - // 마감 정리 로직 (필요 시 주석 해제하여 사용) - println("📢 [마감 정리 체크] ${trade.name}") - } catch (e: Exception) { - println("⚠️ [마감 에러] ${trade.name}: ${e.message}") - } - delay(200) - } - } +// private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { +// val activeTrades = DatabaseFactory.findAllMonitoringTrades() +// val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() +// val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() +// +// activeTrades.forEach { trade -> +// try { +// if (!realHoldings.containsKey(trade.code)) { +// DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED) +// return@forEach +// } +// // 마감 정리 로직 (필요 시 주석 해제하여 사용) +// println("📢 [마감 정리 체크] ${trade.name}") +// } catch (e: Exception) { +// println("⚠️ [마감 에러] ${trade.name}: ${e.message}") +// } +// delay(200) +// } +// } fun stopDiscovery() { discoveryJob?.cancel() diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index 93c846f..9f39276 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -20,7 +20,9 @@ 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 @@ -89,13 +91,14 @@ fun DashboardScreen() { } } - DisposableEffect(Unit) { - // 1. 화면 진입 시: 자동 발굴 루프 시작 - // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 - // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. - AutoTradingManager.startAutoDiscoveryLoop(tradeService,callback) + LaunchedEffect(Unit) { + // 화면이 완전히 그려지고 안정화될 때까지 1초 대기 + delay(1000) + AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback) + } - // 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리) +// 리소스 정리는 여전히 DisposableEffect에서 수행 + DisposableEffect(Unit) { onDispose { AutoTradingManager.stopDiscovery() } @@ -109,7 +112,7 @@ fun DashboardScreen() { // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 LaunchedEffect(refreshTrigger) { - setupAutoTradingWatchdog(tradeService,callback) +// setupAutoTradingWatchdog(tradeService,callback) } suspend fun syncAndExecute(orderNo: String) { @@ -181,10 +184,12 @@ fun DashboardScreen() { val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) val monitoringCodes = monitoringTrades.map { it.code }.toSet() wsManager.updateSubscriptions(monitoringCodes) - AutoTradingManager.resumePendingSellOrders(tradeService) + refreshTrigger++ } + + // 3. 실시간 체결 통보 핸들러 (주문번호 중심) wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy -> scope.launch { @@ -365,68 +370,68 @@ fun DashboardScreen() { ) } - items(items.size) { index -> - val configKey = items.get(index) + items(items.size) { index -> + val configKey = items.get(index) - // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) - var localText by remember(configKey) { - mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") - } + // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) + var localText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } - var labelText 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 + val saveAction = { + var newValue = localText.toDoubleOrNull() ?: 0.0 // if (configKey.name.contains("PROFIT")) { // newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) // } - KisSession.config.setValues(configKey, newValue) - DatabaseFactory.saveConfig(KisSession.config) - println("💾 저장됨: ${configKey.label} = $newValue") + 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(configKey) + 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(configKey) + KisSession.config.getValues( - ConfigIndex.TAX_INDEX)}" + 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(configKey) + 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) { + 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() } - }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Decimal - ), - keyboardActions = KeyboardActions( - // 3. 엔터(Done) 키를 눌렀을 때 저장 - onDone = { - saveAction() - } - ), - singleLine = true - ) - } + ), + singleLine = true + ) + } } } }