From a51ffb6193a7ad25cba99d96106a22db3c9c49ee Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 3 Feb 2026 18:07:18 +0900 Subject: [PATCH] ... --- src/main/kotlin/model/StockModels.kt | 4 +- src/main/kotlin/network/KisTradeService.kt | 8 +- src/main/kotlin/network/NewsService.kt | 2 +- src/main/kotlin/network/RagService.kt | 78 +++++---- src/main/kotlin/service/AutoTradingManager.kt | 154 +++++++++++++++--- src/main/kotlin/ui/AiAnalysisView.kt | 1 + src/main/kotlin/ui/DashboardScreen.kt | 22 +++ src/main/kotlin/ui/IntegratedOrderSection.kt | 37 ++++- src/main/kotlin/ui/StockDetailArea.kt | 6 +- 9 files changed, 251 insertions(+), 61 deletions(-) diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index 8c9d111..7c6e983 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -28,7 +28,8 @@ data class BalanceSummary( val tot_evlu_amt: String = "0", // 총 평가금액 val evlu_pfls_rt: String = "0.0", // 총 수익률 (에러 발생 지점: 기본값 추가로 해결) val asst_icrt: String = "0.0", // 일부 환경에서 수익률 필드명 - val nass_amt: String = "0" // 순자산 금액 + val nass_amt: String = "0" , // 순자산 금액 + val dnca_tot_amt: String = "0" ) @Serializable data class RankingResponse( @@ -116,6 +117,7 @@ data class UnifiedStockHolding( data class UnifiedBalance( val totalAsset: String, // 총 평가자산 val totalProfitRate: String, // 총 수익률 + val deposit: String, val holdings: List // 통합 보유 종목 리스트 ) diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index af93c59..90c790f 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -41,7 +41,7 @@ object KisTradeService { // [수정] 모든 로그(Headers + Body)를 찍도록 설정 install(Logging) { logger = Logger.DEFAULT - level = LogLevel.BODY + level = LogLevel.NONE } } @@ -84,10 +84,12 @@ object KisTradeService { val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) + (ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) + val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L Result.success(UnifiedBalance( totalAsset = String.format("%,d", totalAmt), totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0", + deposit = String.format("%,d", depositAmt), holdings = combinedHoldings )) } catch (e: Exception) { Result.failure(e) } @@ -220,7 +222,7 @@ object KisTradeService { val body = response.body() val output2 = body["output2"]?.jsonArray - println("output2 ${output2}") +// println("output2 ${output2}") val candles = output2?.map { element -> val obj = element.jsonObject CandleData( @@ -394,7 +396,7 @@ object KisTradeService { val cano = pureAccount.take(8) val acntPrdtCd = pureAccount.takeLast(2) return try { - println("orgNo") +// println("orgNo") val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-rvsecncl") { header("authorization", "Bearer ${config.tradeToken}") header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index d2b743c..723b17f 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -29,7 +29,7 @@ object NewsService { } install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL + level = LogLevel.NONE } } diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index f05f792..2e515a4 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -52,9 +52,9 @@ object RagService { fun active() { - println("[Cache] Active") +// println("[Cache] Active") if (UrlCacheManager.isInitialized()) return - println("[Cache] initialize") +// println("[Cache] initialize") UrlCacheManager.initialize(embeddingStore, embeddingModel) } @@ -130,32 +130,39 @@ object RagService { suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) { // 1. 10분간의 데이터 가져오기 (API 호출) coroutineScope { - var tradingDecision : TradingDecision = TradingDecision() - tradingDecision.stockCode = stockCode - var corpInfo = DartCodeManager.getCorpCode(stockCode) - corpInfo?.stockName = stockName - corpInfo?.let { NewsService.fetchAndIngestNews(it) } + try { - val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } - tradingDecision.financialData = financialDataDeferred.await() - result(tradingDecision,false) + var tradingDecision: TradingDecision = TradingDecision() + tradingDecision.stockCode = stockCode + var corpInfo = DartCodeManager.getCorpCode(stockCode) + corpInfo?.stockName = stockName + tradingDecision.stockName = stockName + tradingDecision.corpName = corpInfo?.cName ?: "" + corpInfo?.let { NewsService.fetchAndIngestNews(it) } - tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() - result(tradingDecision,false) + val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } - val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" - val questionEmbedding = embeddingModel.embed(question).content() - val searchResult = embeddingStore.search( - EmbeddingSearchRequest.builder() - .queryEmbedding(questionEmbedding) - .maxResults(3) - .build() - ) - tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } - result(tradingDecision,false) - result(decideTrading(stockCode, tradingDecision),true) + tradingDecision.financialData = financialDataDeferred.await() + result(tradingDecision, false) + tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() + result(tradingDecision, false) + + val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" + val questionEmbedding = embeddingModel.embed(question).content() + val searchResult = embeddingStore.search( + EmbeddingSearchRequest.builder() + .queryEmbedding(questionEmbedding) + .maxResults(3) + .build() + ) + tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } + result(tradingDecision, false) + result(decideTrading(stockCode, tradingDecision), true) + }catch (e: Exception) { + e.printStackTrace() + } } } @@ -220,7 +227,7 @@ object RagService { """.trimIndent() val response = chatModel.chat(UserMessage.from(finalPrompt)) - println(response) +// println(response) return response.aiMessage().text() } @@ -271,7 +278,7 @@ object RagService { val response = chatModel.chat(UserMessage.from(prompt)) val rawResponse = response.aiMessage().text() val jsonResponse = JsonSanitizer.formatJson(rawResponse) - println("📥 [AI Raw JSON]:\n$jsonResponse") +// println("📥 [AI Raw JSON]:\n$jsonResponse") // 2. 유연한 파서 설정 (소수점 및 예외 상황 대응) val lenientJson = Json { @@ -283,12 +290,14 @@ object RagService { // JSON 파싱 (Kotlinx Serialization 활용) return try { - println(jsonResponse) +// println(jsonResponse) val decision = lenientJson.decodeFromString(jsonResponse) decision.financialData = tempDecision.financialData decision.newsContext = tempDecision.newsContext decision.techSummary = tempDecision.techSummary decision.stockCode = tempDecision.stockCode + decision.corpName = tempDecision.corpName + decision.stockName = tempDecision.stockName decision } catch (e: dev.langchain4j.exception.InternalServerException) { // 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리 @@ -308,11 +317,14 @@ object RagService { } @Serializable class TradingDecision { - + var corpName : String = "" + var stockName : String = "" val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지) val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스) val midTermScore: Double = 0.0 // 중기 (주봉/재무) val longTermScore: Double = 0.0 + // [추가] 화면 전환용 종목명 + var currentPrice: Double = 0.0 var stockCode: String = "" var decision: String? = null var reason: String? = null @@ -323,12 +335,18 @@ class TradingDecision { fun profitPossible() = listOf(ultraShortScore, - shortTermScore, - midTermScore, - longTermScore).average() + shortTermScore, + midTermScore, + longTermScore).average() + + fun safePossible() = + listOf( + midTermScore, + longTermScore).average() override fun toString(): String { return """ +$corpName($stockName) 수익실현 가능성 : ${profitPossible()} ultraShortScore :$ultraShortScore shortTermScore :$shortTermScore diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 8bd4b94..b6fc307 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -3,8 +3,13 @@ package service import TradingDecision import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData +import model.RankingType +import network.KisTradeService import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId @@ -17,30 +22,139 @@ typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit object AutoTradingManager { private val scope = CoroutineScope(Dispatchers.Default) val targetStocks = mutableListOf>() + // 자동 발굴 루프 제어용 Job + private var discoveryJob: Job? = null - fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) { - targetStocks.add(Pair(stockName, stockCode)) - startTradingLoop(stockName,stockCode,result) + + + fun startAutoDiscoveryLoop( + tradeService: KisTradeService, + callback: TradingDecisionCallback + ) { + if (discoveryJob?.isActive == true) return + + discoveryJob = scope.launch { + println("🚀 [AutoTrading] 5분 주기 자동 발굴 시작") + + while (discoveryJob?.isActive == true) { + try { + // 1. [체크] 현재 잔고 및 보유 종목 조회 + val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() + val myHoldings = balanceResult?.holdings?.map { it.code }?.toSet() ?: emptySet() + 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()) } + + val volList = volRankDeferred.await() + val riseList = riseRankDeferred.await() + +// [수정] 2. 의미 있는 후보군 선정 (단순 상위 15개가 아님) + +// (A) 거래량 상위 종목 중: 현재가 기준 등락률이 0% ~ 20% 사이인 것만 필터링 -> 상위 10개 + val volCandidates = volList + .filter { stock -> + val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 + rate in 0.0..20.0 // 0% 초과 20% 이하 + } + .take(10) + +// (B) 상승률 상위 종목 중: 너무 급등한(20% 초과) 종목은 제외하고, 적당히 오르고 있는 종목만 필터링 -> 상위 10개 +// 보통 상승률 랭킹은 상한가(30%)부터 내려오므로, 앞쪽의 급등주를 건너뛰어야 함 + val riseCandidates = riseList + .filter { stock -> + val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 + rate in 3.0..20.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간 + } + .take(10) + +// 3. 두 리스트 합치기 (중복 제거) + val candidates = (volCandidates + riseCandidates).distinctBy { it.code } + + println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...") + + candidates.forEach { stock -> + // [조건 1] 이미 보유한 종목 제외 + if (myHoldings.contains(stock.code)) return@forEach + + val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0 + + // [조건 2] 최소 1주 매수 가능 여부 + if (currentPrice > myCash) return@forEach + + // 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 상태 업데이트 (싱글톤이므로 순차 처리 필수) + TechnicalAnalyzer.clear() + TechnicalAnalyzer.daily = dailyData + TechnicalAnalyzer.weekly = weeklyData + TechnicalAnalyzer.monthly = monthlyData + TechnicalAnalyzer.min30 = min30Data + + // 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지) + RagService.processStock(stock.name, stock.code) { decision, isSuccess -> + if (decision != null) { + decision.stockName = stock.name + decision.currentPrice = current // 차트에서 확인한 최신 현재가 주입 + } + callback(decision, isSuccess) // DashboardScreen으로 전달 + } + + // 분석 후 잠시 대기 (서버 부하 조절) + delay(2000) + } + } + } + delay(100) // 종목 간 API 호출 간격 + } + println("💤 사이클 종료. 5분 대기...") + } catch (e: Exception) { + println("⚠️ 루프 오류: ${e.message}") + } + delay(5 * 60 * 1000) // 5분 + } + } } - fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) { + // 루프 중단 함수 + fun stopDiscovery() { + discoveryJob?.cancel() + discoveryJob = null + println("🛑 [AutoTrading] 자동 발굴 중단됨") + } + + // 기존 단일 종목 추가 로직 (유지) + fun addStock(stockName: String, stockCode: String, result: TradingDecisionCallback) { scope.launch { - println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}") -// targetStocks.forEach { stockCode -> - launch { // 종목별 병렬 분석 (M3 Pro 파워 활용) - RagService.processStock(stockName, stockCode,result) -// {decision,b -> -//// when (decision?.decision) { -//// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수") -//// "SELL" -> executeOrder(stockCode, "매도") -//// else -> println("[$stockCode] 관망 유지: ${decision?.reason}") -//// } -// result(decision,b) -// } - } -// } -// targetStocks.re -// delay(10 * 60 * 1000) // 10분 대기 + RagService.processStock(stockName, stockCode, result) } } diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index a96dbeb..5048d4f 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -70,6 +70,7 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra AutoTradingManager.addStock(stockName,stockCode) { decision,success -> aiOpinion = decision.toString() isAnalyzing = !success + tradingDecisionCallback.invoke(decision,success) } } catch (e: Exception) { aiOpinion = "분석 중 오류 발생: ${e.message}" diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index f00ea8e..53d2ea7 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -17,6 +17,7 @@ import model.KisSession import model.StockBasicInfo import network.KisTradeService import network.KisWebSocketManager +import service.AutoTradingManager @Composable fun DashboardScreen() { @@ -32,6 +33,27 @@ fun DashboardScreen() { var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 var completeTradingDecision by remember { mutableStateOf(null) } // 단순 종목 선택 시 + DisposableEffect(Unit) { + // 1. 화면 진입 시: 자동 발굴 루프 시작 + // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 + // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. + AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess -> + if (isSuccess && decision != null) { + + selectedStockCode = decision.stockCode + selectedStockName = decision.stockName + isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 + + // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거 + completeTradingDecision = decision + } + } + + // 2. 화면 이탈 시(앱 종료 등): 루프 중단 (리소스 정리) + onDispose { + AutoTradingManager.stopDiscovery() + } + } // 중앙 관리용 상태들 var refreshTrigger by remember { mutableStateOf(0) } diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 13efe18..015a66a 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -70,7 +70,7 @@ fun IntegratedOrderSection( } var profitRate by remember(monitoringItem) { - mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0") + mutableStateOf(monitoringItem?.profitRate?.toString() ?: "2.0") } var stopLossRate by remember(monitoringItem) { mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-2.0") @@ -114,12 +114,43 @@ fun IntegratedOrderSection( } } LaunchedEffect(completeTradingDecision) { + val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 + val MIN_MID_SCORE = 65.0 // 최소 중기 점수 (주봉/재무) + println("completeTradingDecision = $completeTradingDecision") if (completeTradingDecision != null && completeTradingDecision.stockCode.equals(stockCode)) { + println(completeTradingDecision?.decision) + fun resultCheck(completeTradingDecision :TradingDecision) { + println(""" + ${completeTradingDecision.corpName} + ${completeTradingDecision.confidence} + ${completeTradingDecision.profitPossible()} + ${completeTradingDecision.safePossible()} + """.trimIndent()) + // 2. 조건 검사: 신뢰도 80 이상 AND 중기 점수 70 이상 + if (completeTradingDecision.confidence >= MIN_CONFIDENCE && + completeTradingDecision.profitPossible() >= MIN_MID_SCORE && + completeTradingDecision.safePossible() > MIN_MID_SCORE) { + + println("🚀 [조건 만족] 강력 매수 시그널 포착 -> 자동 매수 진행 (1주) ${completeTradingDecision.stockCode}") + // 3. 매수 실행 (자동 감시 켜기: true, 수량: 1주) + // 수량은 필요에 따라 로직으로 계산하여 변경 가능 (예: 자산의 10% 등) + excuteTrade(willEnableAutoSell = true, orderQty = "1") + + } else { + println("✋ [조건 미달] 매수 의견이나 점수 부족으로 관망") + } + } when (completeTradingDecision?.decision) { - "BUY" -> if (completeTradingDecision.confidence > 70) excuteTrade(true, "1") + "BUY" -> { + println("[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}") + resultCheck(completeTradingDecision) + } "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") - else -> println("[$stockCode] 관망 유지: ${completeTradingDecision?.reason}") + else -> { + resultCheck(completeTradingDecision) + println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}") + } } } } diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index a6d1542..e12f6e8 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -94,20 +94,20 @@ fun StockDetailSection( launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { daySummary = it.takeLast(7) TechnicalAnalyzer.daily = it - println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") +// println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") } } // 최근 7일 launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) TechnicalAnalyzer.weekly = it - println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") +// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") } } // 최근 4주 launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { monthSummary = it.takeLast(6) // 최근 6개월 yearSummary = it.takeLast(36) // 최근 3년 TechnicalAnalyzer.monthly = it - println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") +// println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") } } launch {