From aa1f3817bb19c486b5a0b0c7d3f5824887a7a027 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 6 Feb 2026 17:53:17 +0900 Subject: [PATCH] ... --- src/main/kotlin/Main.kt | 8 + src/main/kotlin/model/AppConfig.kt | 2 +- src/main/kotlin/model/StockModels.kt | 30 +- src/main/kotlin/network/KisTradeService.kt | 32 +- src/main/kotlin/service/AutoTradingManager.kt | 430 +++++++----------- src/main/kotlin/service/DynamicNewsScraper.kt | 47 +- src/main/kotlin/service/LlamaServerManager.kt | 2 +- src/main/kotlin/ui/DashboardScreen.kt | 33 +- src/main/kotlin/ui/IntegratedOrderSection.kt | 6 +- 9 files changed, 274 insertions(+), 316 deletions(-) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index b91843c..97a28bf 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -15,16 +15,23 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.transaction import model.AppConfig import model.KisSession import network.DartCodeManager +import network.KisTradeService import service.LlamaServerManager import network.NewsService import org.jetbrains.exposed.sql.selectAll +import service.AutoTradingManager import service.SystemSleepPreventer +import service.TradingDecisionCallback import ui.DashboardScreen import ui.SettingsScreen @@ -33,6 +40,7 @@ enum class AppScreen { Settings, Dashboard } fun main() = application { SystemSleepPreventer.start() + LaunchedEffect(Unit) { // NewsService나 KisTradeService에서 사용하는 client를 전달 DartCodeManager.updateCorpCodes(HttpClient(CIO) { diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index b3cf2b4..ddff93c 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -3,7 +3,7 @@ package model import java.time.LocalDateTime const val feesAndTaxRate = 0.33 -const val minimumNetProfit = 0.4 +const val minimumNetProfit = 0.5 const val buyWeight = 2.0 data class AppConfig( diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index 9dd0741..8dbcf71 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -59,32 +59,10 @@ enum class RankingType( VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", "0"), // BEFORE("장전예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"), // AFTER("장후예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "1") - FOREIGNER_BUY("외인순매수", "FHPST01720000", "20172", "uapidomestic-stockv1quotationsfrgn-buy-rank", "0"), - INSTITUTION_BUY("기관순매수", "FHPST01730000", "20173", "uapidomestic-stockv1quotationsinst-buy-rank", "0"), - - // 신규: 재무/지표 - PER_RANK("PER", "FHPST01760000", "20176", "uapidomestic-stockv1quotationsper-rank", "0"), - PBR_RANK("PBR", "FHPST01770000", "20177", "uapidomestic-stockv1quotationspbr-rank", "0"), - DIVIDEND("배당률", "FHPST01800000", "20180", "uapidomestic-stockv1quotationsdividend-rank", "0"), - - // 신규: 시간외/예상 - AFTER_HOURS_VOLUME("시간외거래량", "FHPST01810000", "20181", "uapidomestic-stockv1rankingafterhours-volume", "0"), - EXPECTED_RISE("예상상승", "FHPST01820000", "20182", "uapidomestic-stockv1rankingexp-trans-updown", "0"), - EXPECTED_FALL("예상하락", "FHPST01820000", "20182", "uapidomestic-stockv1rankingexp-trans-updown", "1"), - - // 신규: 체결/호가/신고가 등 - EXEC_STRENGTH("체결강도", "FHPST01780000", "20178", "uapidomestic-stockv1quotationsexec-strength", "0"), - BID_ASK_VOLUME("호가잔량", "FHPST01790000", "20179", "uapidomestic-stockv1quotationsbid-ask-volume", "0"), - NEW_HIGH("52주신고가", "FHPST01690000", "20169", "uapidomestic-stockv1rankingsh-52w-high", "0"), - - // 신규: 신용/공매도/대량 - MARGIN_BALANCE("신용잔고", "FHPST01830000", "20183", "uapidomestic-stockv1quotationsmargin-balance", "0"), - SHORT_SELL("공매도", "FHPST01840000", "20184", "uapidomestic-stockv1quotationsshort-sell", "0"), - LARGE_DEAL("대량체결", "FHPST01850000", "20185", "uapidomestic-stockv1quotationslarge-deal", "0"), - - // 기타 인기 (KIS HTS 순위분석 기반) - INTEREST_TOP("관심순", "FHPST01860000", "20186", "uapidomestic-stockv1rankinginterest-top", "0"), - COMPANY_TRADE("당사매매", "FHPST01870000", "20187", "uapidomestic-stockv1rankingcompany-trade", "0") + AFTER_HOURS_VOLUME("시간외거래량", "FHPST01810000", "20181", "/uapi/domestic-stock/v1/rankingafterhours-volume", "0"), + EXPECTED_RISE("예상상승", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"), + NEW_HIGH("52주신고가", "FHPST01690000", "20169", "/uapi/domestic-stock/v1/rankingsh-52w-high", "0"), + COMPANY_TRADE("당사매매", "FHPST01860000", "20187", "/uapi/domestic-stock/v1/ranking/traded-by-company", "0") } @Serializable diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 8d22c33..7c18107 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -147,13 +147,13 @@ object KisTradeService { // RankingType.BEFORE -> { // parameter("FID_MKOP_CLS_CODE", type.sortCode) // } - RankingType.FOREIGNER_BUY, RankingType.INSTITUTION_BUY -> { - parameter("FID_BLNG_CLS_CODE", type.sortCode) // 순매수용 - parameter("FID_INPUT_CNT_1", "5") // 5일 누적 등 조정 가능 - } - RankingType.PER_RANK, RankingType.PBR_RANK -> { - parameter("FID_FINCL_CLS_CODE", type.sortCode) // 재무비율용 - } +// RankingType.FOREIGNER_BUY, RankingType.INSTITUTION_BUY -> { +// parameter("FID_BLNG_CLS_CODE", type.sortCode) // 순매수용 +// parameter("FID_INPUT_CNT_1", "5") // 5일 누적 등 조정 가능 +// } +// RankingType.PER_RANK, RankingType.PBR_RANK -> { +// parameter("FID_FINCL_CLS_CODE", type.sortCode) // 재무비율용 +// } RankingType.AFTER_HOURS_VOLUME -> { parameter("FID_TIME_OUT_CLS_CODE", "1") // 시간외 구분 } @@ -188,8 +188,24 @@ object KisTradeService { } } val body = response.body() + if (listOf( + RankingType.VOLUME_POWER, + RankingType.EXPECTED_RISE, + RankingType.COMPANY_TRADE + ).contains(type)) { + println("${type.name} , ${body}" ) + } if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1)) - } catch (e: Exception) { Result.failure(e) } + } catch (e: Exception) { + if (listOf( + RankingType.VOLUME_POWER, + RankingType.EXPECTED_RISE, + RankingType.COMPANY_TRADE + ).contains(type)) { + println("${type.name} , ${e.message}" ) + } + Result.failure(e) + } } /** diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 728be44..d7ff5bd 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -4,12 +4,17 @@ import TradingDecision import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import model.CandleData +import model.RankingStock import model.RankingType import network.DartCodeManager import network.KisTradeService @@ -17,296 +22,215 @@ import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicLong import kotlin.collections.List import kotlin.math.* // service/AutoTradingManager.kt typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit object AutoTradingManager { - private val scope = CoroutineScope(Dispatchers.Default) - val targetStocks = mutableListOf>() - // 자동 발굴 루프 제어용 Job + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var discoveryJob: Job? = null + // 모니터링을 위한 상태 변수 + private val lastTickTime = AtomicLong(System.currentTimeMillis()) + private var watchdogJob: Job? = null + + // 설정 상수 + private const val MIN_RISE_RATE = 0.1 + private const val MAX_RISE_RATE = 15.0 + private const val CYCLE_TIMEOUT = 10 * 60 * 1000L // 한 사이클 최대 10분 + private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 + private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 + + fun isRunning(): Boolean = discoveryJob?.isActive == true + + /** + * 자동 발굴 루프 시작 및 Watchdog 실행 + */ + fun startAutoDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { + if (isRunning()) return + + // 1. 기존 Watchdog이 있다면 제거 후 새로 시작 + watchdogJob?.cancel() + watchdogJob = scope.launch { + while (isActive) { + delay(WATCHDOG_CHECK_INTERVAL) + val now = System.currentTimeMillis() + if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) { + println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.") + restartLoop(tradeService, callback) + } + } + } + + // 2. 메인 루프 실행 + runDiscoveryLoop(tradeService, callback) + } + + private fun runDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { + discoveryJob = scope.launch { + println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") + + while (isActive) { + try { + lastTickTime.set(System.currentTimeMillis()) // 생존 신고 + + withTimeout(CYCLE_TIMEOUT) { + println("⏱️ [Cycle Start] ${LocalTime.now()}") + + // [프로세스 1] 장 마감 및 잔고 체크 + val now = LocalTime.now(ZoneId.of("Asia/Seoul")) + if (now.isAfter(LocalTime.of(15, 30)) && now.isBefore(LocalTime.of(15, 30))) { + executeClosingLiquidation(tradeService) + return@withTimeout + } + + val balance = tradeService.fetchIntegratedBalance().getOrNull() + 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 } + // [프로세스 2] 후보군 수집 + val candidates = fetchCandidates(tradeService) + .filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE } + .filter { it.code !in myHoldings && it.code !in pendingStocks } + .distinctBy { it.code } + + // [프로세스 3] 종목별 순회 분석 + candidates.forEach { stock -> + lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고 + processSingleStock(stock, myCash, tradeService, callback) + delay(300) + } + + println("⏱️ [Cycle End] ${LocalTime.now()}") + } + } catch (e: TimeoutCancellationException) { + println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") + } catch (e: Exception) { + println("⚠️ [Loop Error] ${e.message}") + delay(10000) + } + + waitForNextCycle(3) + } + } + } + + private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { + try { + // 개별 종목 분석은 최대 2분으로 제한 + withTimeout(120000L) { + val corpInfo = DartCodeManager.getCorpCode(stock.code) + if (corpInfo?.cName.isNullOrEmpty()) { + // println("⏭️ [제외] ${stock.name}: 법인명 정보를 찾을 수 없음") + return@withTimeout + } + + val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout + val today = dailyData.lastOrNull() ?: return@withTimeout + val currentPrice = today.stck_prpr.toDouble() + + if (currentPrice > myCash || currentPrice > 15000 || currentPrice < 900) return@withTimeout + + println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})") + callback(TradingDecision().apply { + this.stockCode = stock.code + this.confidence = -1.0 + this.stockName = stock.name + }, false) + + val analyzer = coroutineScope { + val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } + val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } + val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) } + TechnicalAnalyzer().apply { + this.daily = dailyData + this.min30 = min30.await() + this.weekly = weekly.await() + this.monthly = monthly.await() + } + } + + RagService.processStock(analyzer, stock.name, stock.code) { decision, isSuccess -> + callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) + } + println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})") + } + } catch (e: Exception) { + println("❌ [Stock Error] ${stock.name}: ${e.message}") + } + } + + private suspend fun fetchCandidates(tradeService: KisTradeService): List = coroutineScope { + listOf( + async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, + async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, +// async { tradeService.fetchMarketRanking(RankingType.EXPECTED_RISE, true).getOrDefault(emptyList()) }, +// async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) } + ).awaitAll().flatten() + } + + private fun restartLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { + discoveryJob?.cancel() + startAutoDiscoveryLoop(tradeService, callback) + } + + private suspend fun waitForNextCycle(minutes: Int) { + println("💤 대기 모드 진입...") + val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L) + while (System.currentTimeMillis() < endWait && isRunning()) { + lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 + delay(10000) + } + } + + 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() + val realHoldings = balanceResult?.holdings?.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) + 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)}%)") - } - } + // 마감 정리 로직 (필요 시 주석 해제하여 사용) + println("📢 [마감 정리 체크] ${trade.name}") } catch (e: Exception) { - println("⚠️ [마감 정리 중 에러] ${trade.name}: ${e.message}") + println("⚠️ [마감 에러] ${trade.name}: ${e.message}") } - delay(200) // API 호출 간격 조절 + delay(200) } } - val MIN = 0.1 - val MAX = 15.0 - fun startAutoDiscoveryLoop( - tradeService: KisTradeService, - callback: TradingDecisionCallback - ) { - if (discoveryJob?.isActive == true) return - - discoveryJob = scope.launch { - println("🚀 [AutoTrading] 5분 주기 자동 발굴 시작") - - 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}") - - // 2. 랭킹 데이터 가져오기 - // 1. 랭킹 데이터 가져오기 (비동기) - val volRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) } - val riseRankDeferred = async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) } -// 거래대금(Amount) 상위 추가 - val amountRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) } - val volumePowerDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) } - val volList = volRankDeferred.await() - val riseList = riseRankDeferred.await() - val amountList = amountRankDeferred.await() - val volumeList = volumePowerDeferred.await() - - - - - -// 3. 리스트 합치기 (중복 제거) - 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 // 너무 과열되지 않은 주도주 - }.filter { myHoldings.contains(it.code) == false && pendingStocks.contains(it.code) == false}.distinctBy { it.code } - - println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...") - - candidates.forEach { stock -> - try { - - - // [조건 1] 이미 보유한 종목 제외 - - 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 - - // [조건 2] 최소 1주 매수 가능 여부 - if (currentPrice > myCash || currentPrice > 10000) { - println("⏭️ [제외] ${stock.name}: 주가($currentPrice)가 예산 초과") - return@forEach - } - callback(TradingDecision().apply { - this.stockCode = stock.code - this.confidence = -1.0 - this.stockName = stock.name - }, false) - // 3. 일봉 데이터 조회 (필터링 용도 + TechnicalAnalyzer 입력용) - val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true) - val dailyData = dailyResult.getOrNull() - val todayCandle = dailyData?.lastOrNull() - - if (dailyData != null && todayCandle != null) { - - val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0 - val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0 - - if (open > 0) { - val riseRate = (current - open) / open * 100 - - // [조건 3] 상승 중(양봉)이면서 20% 이하 상승 - if (riseRate > 0 && riseRate <= 20.0) { - println( - "✨ [발굴] ${stock.name} (+${ - String.format( - "%.1f", - riseRate - ) - }%) -> 데이터 수집 및 분석" - ) - - // [핵심 수정] AI 분석 전 필요한 차트 데이터(30분, 주봉, 월봉)를 모두 가져와 TechnicalAnalyzer에 주입 - // 비동기로 동시에 요청하여 속도 향상 - val min30Def = async { - tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) - } - val weekDef = async { - tradeService.fetchPeriodChartData(stock.code, "W", true) - .getOrDefault(emptyList()) - } - val monthDef = async { - tradeService.fetchPeriodChartData(stock.code, "M", true) - .getOrDefault(emptyList()) - } - - val min30Data = min30Def.await() - val weeklyData = weekDef.await() - val monthlyData = monthDef.await() - - // TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수) - val t = TechnicalAnalyzer() - t.daily = dailyData - t.weekly = weeklyData - t.monthly = monthlyData - t.min30 = min30Data - - try { - withTimeout(60000L) { // 60초 타임아웃 설정 - RagService.processStock(t, stock.name, stock.code) { decision, isSuccess -> - if (decision != null) { - decision.stockName = stock.name - decision.currentPrice = current - } - callback(decision, isSuccess) - } - } - } catch (e: TimeoutCancellationException) { - println("⏳ [Timeout] ${stock.name} AI 분석 시간 초과로 스킵합니다.") - } catch (e: Exception) { - println("⚠️ [Error] AI 분석 중 오류: ${e.message}") - } - - // 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지) - // 분석 후 잠시 대기 (서버 부하 조절) - delay(2000) - } - } - } - delay(300) // 종목 간 API 호출 간격 - } catch (e: Exception) { - println("⚠️ [오류] ${stock.name} 분석 중 예외 발생: ${e.message}") - } - } - - - // --- 10초 주기 로그 대기 로직 시작 --- - val waitMinutes = 3 - val totalWaitMillis = waitMinutes * 60 * 1000L - val tickMillis = 10 * 1000L - var currentWait = 0L - println("💤 사이클 종료. ${waitMinutes}분 대기...") - println("✅ 이번 사이클 분석 완료.") - while (currentWait < totalWaitMillis && discoveryJob?.isActive == true) { - delay(tickMillis) - currentWait += tickMillis - val leftSec = (totalWaitMillis - currentWait) / 1000 - if (leftSec % 60 == 0L) { - val runtime = Runtime.getRuntime() - val usedMem = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024 - println("📡 [System] 정상 작동 중... (남은 시간: ${leftSec}초 | 메모리 사용: ${usedMem}MB)") - } - } - - } catch (e: Exception) { - println("⚠️ 루프 오류: ${e.message}") - delay(10000) // 오류 발생 시 10초 후 재시도 - } - } - } - } - - // 루프 중단 함수 fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null println("🛑 [AutoTrading] 자동 발굴 중단됨") } - // 기존 단일 종목 추가 로직 (유지) fun addStock(technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) { scope.launch { RagService.processStock(technicalAnalyzer,stockName, stockCode, result) } } + fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) { + if (!isRunning()) { + println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") + startAutoDiscoveryLoop(tradeService, callback) + } else { - - private fun executeOrder(code: String, type: String) { - // 실제 증권사 API 호출 로직 (한국투자증권, 키움 등) - println("🔥 [주문 집행] $code $type 완료") + } } + } data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt index c51a9a4..1c59751 100644 --- a/src/main/kotlin/service/DynamicNewsScraper.kt +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -5,12 +5,14 @@ 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.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeout import model.NewsItem import network.CorpInfo import java.net.URL @@ -163,21 +165,30 @@ object DynamicNewsScraper { .trim() } } - object SafeScraper { - // 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다) - private val semaphore = Semaphore(2) + // 세마포어를 2개로 유지하되, 작업당 타임아웃을 반드시 설정해야 합니다. + private val semaphore = Semaphore(4) + + suspend fun scrapeParallel(corpInfo: CorpInfo, urls: List) = coroutineScope { + val query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}" - suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List) = coroutineScope { - var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}" urls.map { item -> async { - if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) { - try { - semaphore.withPermit { - try { + if (UrlCacheManager.isAlreadyProcessed(item.originallink)) { + // println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵") + return@async + } + + try { + // 세마포어 획득 시도에 타임아웃을 걸어 대기열 정체 방지 + semaphore.withPermit { + // 개별 뉴스 스크래핑에 최대 30~60초 제한 설정 (무한 대기 방지 핵심) + withTimeout(10000L) { + val content = DynamicNewsScraper.fetchFullContent(item.originallink) + + if (content.isNotBlank()) { RagService.ingestWithChunking( - text = DynamicNewsScraper.fetchFullContent(item.originallink), + text = content, newsLink = item.originallink, pubDate = item.pubDate, stockCode = corpInfo.stockCode, @@ -185,20 +196,18 @@ object SafeScraper { corpCode = corpInfo.cCode, stcokName = corpInfo.stockName ) - }catch (e: Exception) { - println("${e.message}") + println("✅ [학습완료] ${item.originallink}") } - } - }catch (e: Exception) { - println("${e.message}") } - println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링") - } else { - println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵") + } catch (e: TimeoutCancellationException) { + println("⏳ [타임아웃] 뉴스 읽기 시간 초과: ${item.originallink}") + } catch (e: Exception) { + println("❌ [스크래핑 에러] ${item.originallink}: ${e.localizedMessage}") } } }.awaitAll() - println("$query 관련 뉴스 ${urls.size}개 학습 완료") + + println("🏁 $query 관련 뉴스 ${urls.size}개 처리 시도 완료") } } \ No newline at end of file diff --git a/src/main/kotlin/service/LlamaServerManager.kt b/src/main/kotlin/service/LlamaServerManager.kt index 0c4e74b..41af845 100644 --- a/src/main/kotlin/service/LlamaServerManager.kt +++ b/src/main/kotlin/service/LlamaServerManager.kt @@ -29,7 +29,7 @@ object LlamaServerManager { "--port", port.toString(), "-c", if (port == 8081) "512" else "8192", // 임베딩용은 컨텍스트가 짧아도 충분합니다. "-ngl", nGpuLayers.toString(), - "-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장 + "-t", "8", // M3 Pro의 성능 코어를 고려하여 6~8개 권장 "--embedding" // 임베딩 기능을 활성화합니다. ) diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index 49656bd..3eea217 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -11,6 +11,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData import model.ExecutionData @@ -22,6 +25,7 @@ import network.KisTradeService import network.KisWebSocketManager import service.AutoTradingManager import service.TechnicalAnalyzer +import service.TradingDecisionCallback import util.MarketUtil import kotlin.collections.mutableListOf @@ -46,11 +50,18 @@ fun DashboardScreen() { var monthSummary by remember { mutableStateOf>(mutableListOf()) } var yearSummary by remember { mutableStateOf>(mutableListOf()) } - DisposableEffect(Unit) { - // 1. 화면 진입 시: 자동 발굴 루프 시작 - // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 - // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. - AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess -> + 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 -> @@ -70,6 +81,13 @@ fun DashboardScreen() { completeTradingDecision = decision } } + } + + DisposableEffect(Unit) { + // 1. 화면 진입 시: 자동 발굴 루프 시작 + // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 + // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. + AutoTradingManager.startAutoDiscoveryLoop(tradeService,callback) // 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리) onDispose { @@ -83,6 +101,11 @@ fun DashboardScreen() { val executionCache = remember { mutableMapOf() } val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 + + LaunchedEffect(refreshTrigger) { + setupAutoTradingWatchdog(tradeService,callback) + } + suspend fun syncAndExecute(orderNo: String) { if (processingIds.contains(orderNo)) return processingIds.add(orderNo) diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 33073d5..6977689 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -177,13 +177,13 @@ fun IntegratedOrderSection( // 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용 val finalMargin = if (totalScore >= HIGH_QUALITY_SCORE) { println("💎 [우량주 포착] 토탈 스코어($totalScore)가 매우 높아 목표 마진을 3.0%로 상향합니다.") - minimumNetProfit + (append * 1.5) + minimumNetProfit * 1.5 } else { - minimumNetProfit + append + minimumNetProfit } println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") - val MAX_BUDGET = 25000.0 + val MAX_BUDGET = 30000.0 // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) val calculatedQty = if (basePrice > 0) { (MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)