This commit is contained in:
lunaticbum 2026-02-19 16:20:15 +09:00
parent 59c8ff4ebb
commit 535989b8ae
3 changed files with 100 additions and 83 deletions

View File

@ -12,6 +12,7 @@ import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.CancellationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.CandleData import model.CandleData
import model.RankingResponse import model.RankingResponse
@ -22,6 +23,7 @@ import model.StockBalanceResponse
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@ -30,6 +32,7 @@ import model.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.coroutines.coroutineContext
object KisTradeService { object KisTradeService {
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
@ -525,6 +528,7 @@ object KisTradeService {
try { try {
do { do {
if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가]
println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)") println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)")
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer ${config.tradeToken}") header("authorization", "Bearer ${config.tradeToken}")
@ -582,6 +586,10 @@ object KisTradeService {
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) {
println(" [잔고조회] 사용자가 화면을 벗어나 조회를 중단합니다.")
throw e
}
println("💥 [Fatal Error] 잔고 조회 중 예외 발생: ${e.message}") println("💥 [Fatal Error] 잔고 조회 중 예외 발생: ${e.message}")
e.printStackTrace() e.printStackTrace()
return Result.failure(e) return Result.failure(e)

View File

@ -19,6 +19,7 @@ import model.ConfigIndex
import model.KisSession import model.KisSession
import model.RankingStock import model.RankingStock
import model.RankingType import model.RankingType
import model.UnifiedBalance
import network.DartCodeManager import network.DartCodeManager
import network.FinancialMapper import network.FinancialMapper
import network.FinancialStatement 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)된 매도 건을 가져옵니다. // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
println("resumePendingSellOrders")
val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED)) val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
println("pendingSells >>> ${pendingSells.size}")
pendingSells.forEach { item -> pendingSells.forEach { item ->
// 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치) // 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치)
val balance = tradeService.fetchIntegratedBalance().getOrNull() // val balance = tradeService.fetchIntegratedBalance().getOrNull()
val holding = balance?.holdings?.find { it.code == item.code } val holding = balance?.holdings?.find { it.code == item.code }
if (holding != null && holding.quantity.toInt() > 0) { if (holding != null && holding.quantity.toInt() > 0) {
@ -123,14 +125,16 @@ object AutoTradingManager {
println("⏱️ [Cycle Start] ${LocalTime.now()}") println("⏱️ [Cycle Start] ${LocalTime.now()}")
// [프로세스 1] 장 마감 및 잔고 체크 // [프로세스 1] 장 마감 및 잔고 체크
val now = LocalTime.now(ZoneId.of("Asia/Seoul")) // val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
//&& now.isBefore(LocalTime.of(15, 30)) // //&& now.isBefore(LocalTime.of(15, 30))
if (now.isAfter(LocalTime.of(15, 30)) ) { // if (now.isAfter(LocalTime.of(15, 30)) ) {
// executeClosingLiquidation(tradeService) //// executeClosingLiquidation(tradeService)
return@withTimeout // return@withTimeout
} // }
val balance = tradeService.fetchIntegratedBalance().getOrNull() val balance = tradeService.fetchIntegratedBalance().getOrNull()
balance?.let { resumePendingSellOrders(tradeService,it) }
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
@ -295,25 +299,25 @@ object AutoTradingManager {
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { // private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
val activeTrades = DatabaseFactory.findAllMonitoringTrades() // val activeTrades = DatabaseFactory.findAllMonitoringTrades()
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() // val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() // val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap()
//
activeTrades.forEach { trade -> // activeTrades.forEach { trade ->
try { // try {
if (!realHoldings.containsKey(trade.code)) { // if (!realHoldings.containsKey(trade.code)) {
DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED) // DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED)
return@forEach // return@forEach
} // }
// 마감 정리 로직 (필요 시 주석 해제하여 사용) // // 마감 정리 로직 (필요 시 주석 해제하여 사용)
println("📢 [마감 정리 체크] ${trade.name}") // println("📢 [마감 정리 체크] ${trade.name}")
} catch (e: Exception) { // } catch (e: Exception) {
println("⚠️ [마감 에러] ${trade.name}: ${e.message}") // println("⚠️ [마감 에러] ${trade.name}: ${e.message}")
} // }
delay(200) // delay(200)
} // }
} // }
fun stopDiscovery() { fun stopDiscovery() {
discoveryJob?.cancel() discoveryJob?.cancel()

View File

@ -20,7 +20,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.CandleData import model.CandleData
import model.ConfigIndex import model.ConfigIndex
@ -89,13 +91,14 @@ fun DashboardScreen() {
} }
} }
DisposableEffect(Unit) { LaunchedEffect(Unit) {
// 1. 화면 진입 시: 자동 발굴 루프 시작 // 화면이 완전히 그려지고 안정화될 때까지 1초 대기
// AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 delay(1000)
// IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback)
AutoTradingManager.startAutoDiscoveryLoop(tradeService,callback) }
// 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리) // 리소스 정리는 여전히 DisposableEffect에서 수행
DisposableEffect(Unit) {
onDispose { onDispose {
AutoTradingManager.stopDiscovery() AutoTradingManager.stopDiscovery()
} }
@ -109,7 +112,7 @@ fun DashboardScreen() {
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
LaunchedEffect(refreshTrigger) { LaunchedEffect(refreshTrigger) {
setupAutoTradingWatchdog(tradeService,callback) // setupAutoTradingWatchdog(tradeService,callback)
} }
suspend fun syncAndExecute(orderNo: String) { suspend fun syncAndExecute(orderNo: String) {
@ -181,10 +184,12 @@ fun DashboardScreen() {
val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
val monitoringCodes = monitoringTrades.map { it.code }.toSet() val monitoringCodes = monitoringTrades.map { it.code }.toSet()
wsManager.updateSubscriptions(monitoringCodes) wsManager.updateSubscriptions(monitoringCodes)
AutoTradingManager.resumePendingSellOrders(tradeService)
refreshTrigger++ refreshTrigger++
} }
// 3. 실시간 체결 통보 핸들러 (주문번호 중심) // 3. 실시간 체결 통보 핸들러 (주문번호 중심)
wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy -> wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
scope.launch { scope.launch {
@ -365,68 +370,68 @@ fun DashboardScreen() {
) )
} }
items(items.size) { index -> items(items.size) { index ->
val configKey = items.get(index) val configKey = items.get(index)
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) { var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
} }
var labelText by remember(configKey) { var labelText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
} }
val saveAction = { val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0 var newValue = localText.toDoubleOrNull() ?: 0.0
// if (configKey.name.contains("PROFIT")) { // if (configKey.name.contains("PROFIT")) {
// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) // newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
// } // }
KisSession.config.setValues(configKey, newValue) KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config) DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue") 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")) { labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( 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)}) = ${localText.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}" ConfigIndex.TAX_INDEX)} "
} else { } else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
} }
}
labelText = if (configKey.name.contains("PROFIT")) { OutlinedTextField(
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( value = localText,
ConfigIndex.TAX_INDEX)}) = ${localText.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues( onValueChange = { localText = it }, // 화면에는 즉시 반영
ConfigIndex.TAX_INDEX)} " label = { Text(labelText) },
} else { modifier = Modifier
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" .fillMaxWidth()
} .onFocusChanged { focusState ->
// 2. 포커스를 잃었을 때 저장
OutlinedTextField( if (!focusState.isFocused) {
value = localText, saveAction()
onValueChange = { localText = it }, // 화면에는 즉시 반영 }
label = { Text(labelText) }, },
modifier = Modifier keyboardOptions = KeyboardOptions(
.fillMaxWidth() imeAction = ImeAction.Done,
.onFocusChanged { focusState -> keyboardType = KeyboardType.Decimal
// 2. 포커스를 잃었을 때 저장 ),
if (!focusState.isFocused) { keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction() saveAction()
} }
}, ),
keyboardOptions = KeyboardOptions( singleLine = true
imeAction = ImeAction.Done, )
keyboardType = KeyboardType.Decimal }
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true
)
}
} }
} }
} }