diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index fe93df9..8b81555 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -132,6 +132,22 @@ object DatabaseFactory { AutoTradeTable.deleteWhere { AutoTradeTable.id eq id } } + fun findAllPendingBuyCodes(): Set { + return transaction { + AutoTradeTable.select { + (AutoTradeTable.status eq "PENDING_BUY") or (AutoTradeTable.status eq "ORDERED") + }.map { it[AutoTradeTable.stockCode] }.toSet() + } + } + + fun findAllMonitoringTrades(): List { + return transaction { + AutoTradeTable.select { + AutoTradeTable.status neq "COMPLETED" + }.map { mapToAutoTradeItem(it) } + } + } + private fun mapToAutoTradeItem(it: ResultRow) = AutoTradeItem( id = it[AutoTradeTable.id], code = it[AutoTradeTable.stockCode], diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 48ab481..728be44 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import model.CandleData import model.RankingType +import network.DartCodeManager import network.KisTradeService import java.time.LocalDateTime import java.time.LocalTime @@ -28,6 +29,78 @@ object AutoTradingManager { private var discoveryJob: Job? = null + private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { + // 1. DB에서 현재 감시 중인(보유 중인) 모든 종목 가져오기 + val activeTrades = DatabaseFactory.findAllMonitoringTrades() +// 2. [추가] 실시간 증권사 잔고 조회 (실제 보유 주식인지 확인용) + val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() + val realHoldings = balanceResult?.holdings + ?.filter { + println("[${it.name}(${it.code})]: evalAmount ${it.evalAmount} , currentPrice : ${it.currentPrice} , ${it.quantity}") + it.quantity.toInt() > 0 && it.evalAmount.toDouble() > (it.currentPrice.toDouble() * it.quantity.toDouble()) } // 수량이 0보다 큰 것만 + ?.associateBy { it.code } ?: emptyMap() + + activeTrades.forEach { trade -> + try { + // [검증] DB에는 MONITORING이지만 실제 잔고에는 없는 경우 처리 + if (!realHoldings.containsKey(trade.code)) { + println("ℹ️ [제외] ${trade.name}: DB에는 감시 중이나 실제 잔고에 수량이 없어 스킵합니다.") + // 필요시 DB 상태를 COMPLETED 등으로 동기화 + DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED) + return@forEach + } + // 2. 수익 상태 먼저 체크 (현재가 조회) + val currentResult = tradeService.fetchChartData(trade.code, true).getOrNull() + val currentPrice = currentResult?.lastOrNull()?.stck_prpr?.toDouble() ?: 0.0 + + if (currentPrice > 0) { + + // 매수가 역산 (목표가와 설정 수익률 기반) + val buyPrice = trade.targetPrice / (1 + trade.profitRate / 100.0) + val netProfitRate = ((currentPrice - buyPrice) / buyPrice * 100) - 0.3 // 수수료/세금 0.3% 차감 + + // 3. 매도 조건 판단 (최소 수익 0.1% 이상 확보 여부) + val isMinimumProfitSecured = netProfitRate >= 0.1 + val isUrgent = LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(15, 20)) + println("orderedPrice ${trade.orderedPrice}, currentPrice ${currentPrice} ") + // 수익이 났거나, 15:20분 이후 긴급 상황인 경우에만 진행 + if (isMinimumProfitSecured || isUrgent) { + val reason = if (isUrgent) "시간 임박(탈출)" else "수익 확보(${String.format("%.2f", netProfitRate)}%)" + println("📢 [마감 정리 대상 포착] ${trade.name} | 사유: $reason") + + // 4. [순서 변경] 수익 확인 후 기존 익절/손절 주문 취소 실행 +// if (!trade.orderNo.isNullOrBlank()) { +// tradeService.cancelOrder(trade.orderNo, trade.code).onSuccess { +// println("✅ [취소 완료] ${trade.name} 기존 주문 취소됨") +// }.onFailure { +// println("ℹ️ [취소 건너뜀] ${trade.name}: ${it.message}") +// } +// } +// +// // 5. 즉시 시장가 매도 실행 (price를 "0"으로 전달) +// tradeService.postOrder( +// stockCode = trade.code, +// qty = trade.quantity.toString(), +// price = "0", // 시장가 주문 +// isBuy = false +// ).onSuccess { +// DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.COMPLETED) +// println("✨ [정리 완료] ${trade.name} 시장가 매도 성공") +// }.onFailure { +// println("❌ [매도 실패] ${trade.name}: ${it.message}") +// } + } else { + // 수익권이 아니면 그대로 유지 (기존 지정가 익절/손절 주문 유지) + // println("⏭️ [유지] ${trade.name}: 현재 수익권 아님 (${String.format("%.2f", netProfitRate)}%)") + } + } + } catch (e: Exception) { + println("⚠️ [마감 정리 중 에러] ${trade.name}: ${e.message}") + } + delay(200) // API 호출 간격 조절 + } + } + val MIN = 0.1 val MAX = 15.0 fun startAutoDiscoveryLoop( @@ -41,9 +114,23 @@ object AutoTradingManager { while (discoveryJob?.isActive == true) { try { + + val now = LocalTime.now(ZoneId.of("Asia/Seoul")) + val isClosingTime = now.isAfter(LocalTime.of(15, 0)) && now.isBefore(LocalTime.of(15, 30)) + + if (isClosingTime) { + println("🕒 [장 마감 모드] 추가 매수를 중단하고 보유 종목 정리를 시작합니다.") + executeClosingLiquidation(tradeService) // 마감 정리 함수 호출 + + // 마감 중에는 1분 단위로 짧게 체크하며 대기 + delay(60 * 1000) + continue + } + // 1. [체크] 현재 잔고 및 보유 종목 조회 val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val myHoldings = balanceResult?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() + val pendingStocks = DatabaseFactory.findAllPendingBuyCodes() val myCash = balanceResult?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L println("💰 보유 현금: ${String.format("%,d", myCash)}원 | 보유 종목 수: ${myHoldings.size}") @@ -68,7 +155,7 @@ object AutoTradingManager { val candidates = (volList + riseList + amountList + volumeList + async { tradeService.fetchMarketRanking(RankingType.FOREIGNER_BUY, true).getOrDefault(emptyList()) + async { tradeService.fetchMarketRanking(RankingType.INSTITUTION_BUY, true).getOrDefault(emptyList()) }.await()}.await()).filter {stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 rate in MIN..MAX // 너무 과열되지 않은 주도주 - }.distinctBy { it.code } + }.filter { myHoldings.contains(it.code) == false && pendingStocks.contains(it.code) == false}.distinctBy { it.code } println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...") @@ -77,7 +164,12 @@ object AutoTradingManager { // [조건 1] 이미 보유한 종목 제외 - if (myHoldings.contains(stock.code)) return@forEach + + var corpInfo = DartCodeManager.getCorpCode(stock.code) + if (corpInfo?.cName?.isNullOrEmpty() ?: true) { + println("⏭️ [제외] ${stock.name}: 법인명이 없음") + return@forEach + } val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0 diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt index 3ab9e96..c51a9a4 100644 --- a/src/main/kotlin/service/DynamicNewsScraper.kt +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -4,6 +4,7 @@ import com.microsoft.playwright.Playwright import com.microsoft.playwright.BrowserType import com.microsoft.playwright.Page import com.microsoft.playwright.options.LoadState +import com.microsoft.playwright.options.WaitUntilState import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -104,14 +105,15 @@ object DynamicNewsScraper { return try { context.use { ctx -> ctx.newPage().use { page -> + page.setDefaultNavigationTimeout(8000.0) delay(Random.nextInt(2000).toLong()) // 1. 리스너 설정 시 예외 처리 강화 blockUnnecessaryResources(page) // 2. 타임아웃을 설정하여 무한 대기 방지 - val options = Page.NavigateOptions().setTimeout(8000.0) - page.navigate(url, options) + + page.navigate(url, Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)) // 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기 page.waitForLoadState(LoadState.LOAD) diff --git a/src/main/kotlin/ui/ActiveTradeRow.kt b/src/main/kotlin/ui/ActiveTradeRow.kt index 31252bb..fe29b91 100644 --- a/src/main/kotlin/ui/ActiveTradeRow.kt +++ b/src/main/kotlin/ui/ActiveTradeRow.kt @@ -94,6 +94,15 @@ fun ActiveTradeRow( ) { Text("취소", fontSize = 11.sp) } + } else { + Button( + onClick = { onCancelClick() }, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.height(28.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) + ) { + Text("취소", fontSize = 11.sp) + } } Text(