From 6494784bbc905bff15efd92c1de5962e47e0feb6 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 8 Apr 2026 14:18:09 +0900 Subject: [PATCH] ... --- .../kotlin/analyzer/AdvancedTradeAssistant.kt | 80 ++++ src/main/kotlin/analyzer/FinancialAnalyzer.kt | 114 +----- src/main/kotlin/analyzer/TechnicalAnalyzer.kt | 76 +++- src/main/kotlin/database/DatabaseFactory.kt | 16 + src/main/kotlin/network/RagService.kt | 108 +++++- src/main/kotlin/service/AutoTradingManager.kt | 348 +++++++++++------- src/main/kotlin/ui/TradingDecisionLog.kt | 3 +- 7 files changed, 471 insertions(+), 274 deletions(-) create mode 100644 src/main/kotlin/analyzer/AdvancedTradeAssistant.kt diff --git a/src/main/kotlin/analyzer/AdvancedTradeAssistant.kt b/src/main/kotlin/analyzer/AdvancedTradeAssistant.kt new file mode 100644 index 0000000..ccf97f5 --- /dev/null +++ b/src/main/kotlin/analyzer/AdvancedTradeAssistant.kt @@ -0,0 +1,80 @@ +package analyzer + +import model.CandleData +import service.InvestmentGrade + +object AdvancedTradeAssistant { + + // 1. VWAP (거래량 가중 평균 단가) 계산기 + // 주로 최근 30분(min30) 데이터를 받아 초단기 세력 평단가를 구합니다. + fun calculateMicroVWAP(candles: List): Double { + if (candles.isEmpty()) return 0.0 + var typicalVolumeSum = 0.0 + var totalVolume = 0.0 + for (candle in candles) { + val typicalPrice = (candle.stck_hgpr.toDouble() + candle.stck_lwpr.toDouble() + candle.stck_prpr.toDouble()) / 3 + val volume = candle.cntg_vol.toDouble() + typicalVolumeSum += typicalPrice * volume + totalVolume += volume + } + return if (totalVolume == 0.0) 0.0 else typicalVolumeSum / totalVolume + } + + // 2. 볼린저 밴드 하단선 계산기 (20일선 기준) + fun calculateBollingerLowerBand(candles: List, period: Int = 20): Double { + if (candles.size < period) return 0.0 + val targetCandles = candles.takeLast(period).map { it.stck_prpr.toDouble() } + val ma20 = targetCandles.average() + + // 표준편차 계산 + val variance = targetCandles.map { Math.pow(it - ma20, 2.0) }.average() + val stdDev = Math.sqrt(variance) + + // 하단선 = 20일 이동평균선 - (2 * 표준편차) + return ma20 - (2 * stdDev) + } + + // 3. 🎯 특정 그레이드에 맞춤형 '매수 조언(Confirmation)' 제공 + fun confirmTrade( + currentGrade: InvestmentGrade, + currentPrice: Double, + min30: List, + daily: List + ): TradeAdvice { + return when (currentGrade) { + // [초단타 등급] VWAP 필터 적용 + InvestmentGrade.LEVEL_1_SPECULATIVE, InvestmentGrade.LEVEL_2_HIGH_RISK -> { + val vwap = calculateMicroVWAP(min30) + if (currentPrice >= vwap) { + // 현재가가 세력 평단가(VWAP) 위에서 놀고 있음 -> 매수 확정 및 가산점 + TradeAdvice(isConfirmed = true, confidenceBonus = +5.0, reason = "VWAP 돌파(강한 수급 방어)") + } else { + // 투매 구간 (세력 평단가 이탈) -> 진입 포기 (LEVEL_0으로 강등 권고) + TradeAdvice(isConfirmed = false, confidenceBonus = -20.0, reason = "VWAP 하향 이탈(투매 위험)") + } + } + + // [우량주 눌림목 등급] 볼린저 밴드 필터 적용 + InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND, InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> { + val lowerBand = calculateBollingerLowerBand(daily) + // 💡 [수정] 1.05배, 1.15배 디테일 추가 + if (lowerBand > 0 && currentPrice <= lowerBand * 1.05) { + TradeAdvice(isConfirmed = true, confidenceBonus = +8.0, reason = "볼린저 밴드 하단 터치(통계적 바닥 확인)") + } else if (lowerBand > 0 && currentPrice > lowerBand * 1.15) { + TradeAdvice(isConfirmed = true, confidenceBonus = -5.0, reason = "볼린저 밴드 하단 미도달(추가 하락 가능성)") + } else { + TradeAdvice(isConfirmed = true, confidenceBonus = 0.0, reason = "정상 추세 구간") + } + } + + else -> TradeAdvice(isConfirmed = true, confidenceBonus = 0.0, reason = "추가 검증 없음") + } + } +} + +// 조언 결과를 담는 데이터 클래스 +data class TradeAdvice( + val isConfirmed: Boolean, + val confidenceBonus: Double, + val reason: String +) \ No newline at end of file diff --git a/src/main/kotlin/analyzer/FinancialAnalyzer.kt b/src/main/kotlin/analyzer/FinancialAnalyzer.kt index 251bbf5..05e3f68 100644 --- a/src/main/kotlin/analyzer/FinancialAnalyzer.kt +++ b/src/main/kotlin/analyzer/FinancialAnalyzer.kt @@ -62,6 +62,17 @@ object FinancialAnalyzer { return buffer.toString() } + fun isBuyConsiderationMet(fs: FinancialStatement): Boolean { + val highProfitability = fs.roe >= 10.0 // ROE 10% 이상 + val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상 + val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전) + val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유) + val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자 + + return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy + } + + fun calculateScore(fs: FinancialStatement): Int { var score = 50.0 // 중립 시작 @@ -101,106 +112,3 @@ object FinancialAnalyzer { } } - -object FinancialAnalyzer2 { - - fun isSafetyBeltMet(fs: FinancialStatement): Boolean { - val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만 - val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상 - val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함 - val isNotCrashing = fs.netIncomeGrowth > -40.0 - return isDebtSafe && isLiquiditySafe && isNotDeficit && isNotCrashing - } - - /** - * [매수 고려] 우량 기업 요건 확인 - * 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다. - */ - fun isBuyConsiderationMet(fs: FinancialStatement): Boolean { - val highProfitability = fs.roe >= 10.0 // ROE 10% 이상 - val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상 - val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전) - val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유) - val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자 - - return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy - } - - - fun toString(fs : FinancialStatement): String { - var buffer = StringBuffer() - val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만 - val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상 - val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함 - val isNotCrashing = fs.netIncomeGrowth > -40.0 - if ((isDebtSafe && isLiquiditySafe && isNotDeficit) == false) { - if (!isDebtSafe)buffer.appendLine( "부채비율 200% 이상") - if (!isLiquiditySafe)buffer.appendLine( "당좌비율 80% 미만") - if (!isNotDeficit)buffer.appendLine( "당기순이익 적자") - if (!isNotCrashing) { buffer.appendLine("당기순이익 급감(${String.format("%.1f", fs.netIncomeGrowth)}%)") } - buffer.appendLine("최소 기준 미달") - } else { - buffer.appendLine("최소 기준 충족") - } - - val highProfitability = fs.roe >= 10.0 // ROE 10% 이상 - val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상 - val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전) - val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유) - val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자 - - if ((highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy) == false) { - if(!highProfitability) buffer.appendLine( "ROE 10% 미만") - if(!strongGrowth) buffer.appendLine( "이익 성장률 15% 미만") - if(!verySafeDebt) buffer.appendLine( "부채비율 100% 이상 (안전성 미달)") - if(!goodLiquidity) buffer.appendLine( "당좌비율 120% 이하 (여유 없음)") - if(!businessHealthy) buffer.appendLine( "본업(영업이익)이 적자") - buffer.appendLine("재무 건전성 및 성장성 미달") - } else { - buffer.appendLine("재무 건전성 및 성장성 충족") - } - - return buffer.toString() - } - /** - * 종합 상태 반환 (UI 또는 로그용) - */ - fun getInvestmentStatus(fs: FinancialStatement): String { - return when { - isBuyConsiderationMet(fs) -> "🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수" - isSafetyBeltMet(fs) -> "⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족" - else -> "🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업" - } - } - - fun calculateScore(fs: FinancialStatement): Int { - var score = 50.0 // 기본 점수 - - // 성장성 (영업이익 증가율) - score += when { - fs.operatingProfitGrowth > 20 -> 20 - fs.operatingProfitGrowth > 0 -> 10 - else -> -10 // 역성장 시 감점 - } - - // 수익성 (ROE) - score += when { - fs.roe > 15 -> 15 - fs.roe > 5 -> 5 - fs.roe < 0 -> -15 // 적자 시 큰 감점 - else -> 0 - } - - // 안정성 (부채비율) - score += when { - fs.debtRatio < 100 -> 15 - fs.debtRatio < 200 -> 5 - else -> -10 - } - - // 유동성 (당좌비율) - if (fs.quickRatio < 100) score -= 10 // 단기 채무 지급 능력 부족 시 감점 - - return score.coerceIn(0.0, 100.0).toInt() - } -} diff --git a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt index f58d688..84ebff0 100644 --- a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt +++ b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt @@ -12,7 +12,7 @@ data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) val shortTerm: Int, // 단기 (일봉/뉴스) val midTerm: Int, // 중기 (주봉/재무) - val longTerm: Int // 장기 (월봉/펀더멘털) + val longTerm: Int // 장기 (월봉/펀더멘털) ) { override fun toString(): String { return """ @@ -28,15 +28,16 @@ data class InvestmentScores( @Serializable class TechnicalAnalyzer { - var monthly: List = emptyList() - var weekly: List = emptyList() - var daily: List = emptyList() + // 주의: min30은 '30분봉'이 아니라 '1분 단위 캔들 30개'를 의미합니다. var min30: List = emptyList() + var daily: List = emptyList() + var weekly: List = emptyList() + var monthly: List = emptyList() fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() } /** - * [신규] 기술적 지표와 추세를 결합한 종합 신호 생성 + * 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성 */ fun generateComprehensiveSignal(): ScalpingSignalModel { val scalpingAnalyzer = ScalpingAnalyzer() @@ -44,10 +45,9 @@ class TechnicalAnalyzer { // 1. 기본 스캘핑 신호 생성 val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish) - - // 2. 점수 정교화 (가점/감점 요인) var refinedScore = baseSignal.compositeScore.toDouble() + // 2. 점수 정교화 (가점/감점 요인) // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) { refinedScore += 10.0 @@ -67,20 +67,64 @@ class TechnicalAnalyzer { val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble()) if (bodyRange > atr * 1.2) refinedScore += 7.0 + // 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석 + if (min30.size >= 15) { + val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들) + + // ① 초단기 추세 가감점 (최근 5분간 변화율) + val microChange = calculateChange(last5Candles) + if (microChange > 1.5) refinedScore += 6.0 // 순간 급등세 (수급 유입) + else if (microChange < -1.5) refinedScore -= 12.0 // 순간 투매 방어 (강력 감점) + + // ② 초단기 거래량 급증 (V-Spike) 확인 + val recentVolume = last5Candles.map { it.cntg_vol.toDouble() }.average() + val pastVolume = min30.dropLast(5).takeLast(10).map { it.cntg_vol.toDouble() }.average() // 그 이전 10분 평균 + + if (pastVolume > 0 && recentVolume > pastVolume * 2.5) { + // 평소보다 거래량이 2.5배 터졌을 때, 양봉이면 매수세 폭발, 음봉이면 쏟아내는 투매 물량 + val isBullishMicro = lastCandle.stck_prpr.toDouble() >= lastCandle.stck_oprc.toDouble() + if (isBullishMicro) refinedScore += 8.0 + else refinedScore -= 10.0 + } + + // ③ 초단기 RSI 과열/과매도 필터링 + val rsiMicro = calculateRSI(last5Candles) + if (rsiMicro > 75.0) refinedScore -= 8.0 // 초단기 꼭지 (추격매수 방지) + else if (rsiMicro < 25.0) refinedScore += 5.0 // 초단기 투매 과도 (기술적 반등 타점 노림) + } + return baseSignal.copy( compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(), successProbPct = (refinedScore * 0.85).coerceAtMost(98.0) ) } + /** + * 억울한 HOLD를 막아주는 유연한 과열 판별 로직 + */ fun isOverheatedStock(): Boolean { - if (min30.size < 20 || daily.size < 20) return false - val currentPrice = min30.last().stck_prpr.toDouble() + if (daily.size < 20) return false + val currentPrice = daily.last().stck_prpr.toDouble() + + // 1. 일봉 20일선 이격도 val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val disparityDaily = (currentPrice / ma20Daily) * 100 - // 이격도 115% 이상이면 주의, 125% 이상이면 과열 - return disparityDaily > 115.0 + // 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인) + val rsiDaily = calculateRSI(daily) + + // 3. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용) + var isMicroOverheated = false + if (min30.size >= 10) { + val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average() + val disparityMin = (currentPrice / ma10Min) * 100 + isMicroOverheated = disparityMin > 105.0 // 10분 평균가 대비 순간적으로 5% 이상 폭등 시 + } + + // 단순히 이격도 115%라고 막는 것이 아니라 3가지 깐깐한 조건 중 하나라도 충족될 때만 과열 판정 + return disparityDaily > 130.0 || // (A) 역대급 폭등 상태 + (disparityDaily > 115.0 && rsiDaily > 75.0) || // (B) 급등 중이면서 일봉 과매수 + (disparityDaily > 115.0 && isMicroOverheated) // (C) 급등 중이면서 10분 내 순간 펌핑(꼭지) } fun calculateScores(financialScore100: Int): InvestmentScores { @@ -100,7 +144,7 @@ class TechnicalAnalyzer { ) } - // --- 유틸리티 함수군 (기존 로직 유지 및 보완) --- + // --- 이하 유틸리티 함수군 (변경 없음) --- fun calculateATR(candles: List, period: Int = 14): Double { if (candles.size < period + 1) return 0.0 val sub = candles.takeLast(period + 1) @@ -170,14 +214,14 @@ class TechnicalAnalyzer { val signal = generateComprehensiveSignal() val currentPrice = min30.last().stck_prpr.toDouble() - // [표준화된 분석 점수] - AI에게 가이드라인 제공 + // [표준화된 분석 점수] val standardizedScores = """ - Financial Health Score: $finScore100 / 100 - Technical Momentum Score: ${signal.compositeScore} / 100 - Market Energy (Volume): ${"%.1f".format(signal.volRatio)}x relative to avg """.trimIndent() - // [시계열 가격 흐름] - AI에게 지지와 저항 맥락 제공 + // [시계열 가격 흐름] val monthlyRange = monthly.takeLast(3).joinToString(" -> ") { "[H:${it.stck_hgpr}, L:${it.stck_lwpr}]" } @@ -188,15 +232,13 @@ class TechnicalAnalyzer { return """ # [Standardized Analysis Summary] $standardizedScores - # [Historical Price Range] - Monthly (Last 3M): $monthlyRange - Weekly (Last 4W): $weeklyRange - Current Price: $currentPrice - # [Technical Context] - Base Position: ${"%.1f".format((currentPrice / daily.takeLast(120).map { it.stck_prpr.toDouble() }.average()) * 100)}% (120MA) - RSI (Daily): ${"%.1f".format(calculateRSI(daily))} """.trimIndent() } -} +} \ No newline at end of file diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index d2bcbec..70bf8bb 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -501,6 +501,22 @@ object TradingLogStore { } } + fun addNotice(name : String, code : String, log: String) { + synchronized(this) { + if (decisionLogs.size > 1000) decisionLogs.removeAt(0) + decisionLogs.add( + LogEntry( + time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + stockName = "$name[$code] 분석", + decision = "NOTICE", + confidence = 100.0, + reason = log + ) + ) + } + } + + fun addAfterMarketLog(name : String, code : String, log: String) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 50e3b93..b214931 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -3,6 +3,7 @@ package network// src/main/kotlin/network/RagService.kt import Defines.EMBEDDING_PORT import Defines.LLM_PORT import TradingLogStore +import analyzer.AdvancedTradeAssistant import analyzer.FinancialAnalyzer import analyzer.FinancialMapper import analyzer.FinancialStatement @@ -55,6 +56,7 @@ import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit //interface TradingAnalyst { @@ -67,6 +69,13 @@ import java.util.concurrent.TimeUnit //} object RagService { + val isSafetyBeltStockCodes = ConcurrentHashMap.newKeySet() + + // (매일 아침 8시 30분 시스템 초기화 시 호출해주어야 함) + fun clearDailyCache() { + isSafetyBeltStockCodes.clear() + println("🧹 [System] 일일 재무 미달 캐시 초기화 완료") + } // 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정 private val embeddingModel = OpenAiEmbeddingModel.builder() @@ -237,6 +246,13 @@ object RagService { this.currentPrice = currentPrice } + if (isSafetyBeltStockCodes.contains(stockCode)) { + // 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스 + // logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime) + result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false) + return@coroutineScope + } + // [1단계] 재무 분석 및 필터링 (가장 빠름) val finStartTime = System.currentTimeMillis() val financialData = NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)?.cCode) @@ -256,12 +272,22 @@ object RagService { if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) { logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime) result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false) + isSafetyBeltStockCodes.add(stockCode) return@coroutineScope } if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) { logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime) - result(tradingDecision.apply { decision = "HOLD"; reason = "매수 타점 미도달" }, false) + if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) { + TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true) + result(tradingDecision.apply { + decision = "RETRY" // 콜백에서 "BUY"가 아니므로 HOLD와 동일하게 취급됨 + reason = "매수 타점 미도달 (재무 우량주로 감시 지속)" + confidence = 65.0 // AutoTradingManager의 재분석 기준(60.0)을 넘기기 위해 부여 + }, true) // isSuccess를 true로 주어야 콜백이 무시하지 않음 + } else { + result(tradingDecision.apply { decision = "HOLD"; reason = "매수 타점 미도달" }, false) + } return@coroutineScope } @@ -286,8 +312,8 @@ object RagService { tradingDecision.newsContext = finalSearchResult.matches().distinct() // 중복 제거 .take(4) // 10개에서 4개로 축소 .joinToString("\n\n") { - it.embedded().text() - } + it.embedded().text() + } val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision) val ragAiDuration = System.currentTimeMillis() - ragStartTime @@ -442,7 +468,7 @@ object RagService { // 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간) val newsStartTime = System.currentTimeMillis() val (newsScore100, newsReason) = tempDecision.newsContext?.let { - getAiNewsScore(it, tempDecision.techSummary ?: "") + getAiNewsScore(stockName,it, tempDecision.techSummary ?: "") } ?: (50.0 to "참조 뉴스 없음") val newsDuration = System.currentTimeMillis() - newsStartTime @@ -460,14 +486,41 @@ object RagService { if (isOverheated) finalConfidence *= 0.85 val totalScore = (scores.ultraShort + scores.shortTerm + scores.midTerm + scores.longTerm) / 4.0 - val grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence) - val synthDuration = System.currentTimeMillis() - synthStartTime + tempDecision.ultraShortScore = scores.ultraShort.toDouble() + tempDecision.shortTermScore = scores.shortTerm.toDouble() + tempDecision.midTermScore = scores.midTerm.toDouble() + tempDecision.longTermScore = scores.longTerm.toDouble() // 5. 최종 결정 및 사유 정리 val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) var finalDecision = "HOLD" var finalReason = "" + var grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence, finScore100) + + var assistantReason = "" + + if (grade != InvestmentGrade.LEVEL_0_SPECULATIVE) { + val advice = AdvancedTradeAssistant.confirmTrade( + currentGrade = grade, + currentPrice = tempDecision.currentPrice, + min30 = tempDecision.analyzer?.min30 ?: emptyList(), + daily = tempDecision.analyzer?.daily ?: emptyList() + ) + + finalConfidence += advice.confidenceBonus + if (!advice.isConfirmed) { + grade = InvestmentGrade.LEVEL_0_SPECULATIVE + assistantReason = " 🚫 [어시스턴트 차단] ${advice.reason}" + } else if (advice.reason.isNotEmpty()) { // 💡 조건 변경 + assistantReason = " ➕ [확인됨: ${advice.reason}]" + } + } + + val synthDuration = System.currentTimeMillis() - synthStartTime + + + when { newsScore100 < 30.0 -> { finalDecision = "HOLD" @@ -477,9 +530,9 @@ object RagService { finalDecision = "HOLD" finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한" } - finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_1_SPECULATIVE -> { + finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_0_SPECULATIVE -> { finalDecision = "BUY" - finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수" + finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수 | $assistantReason" } finalConfidence < 40.0 -> { finalDecision = "SELL" @@ -497,6 +550,9 @@ object RagService { println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms") return TradingDecision().apply { + this.technicalScore = techScore100 + this.financialScore = finScore100 + this.systemScore = sysScore100 this.stockCode = tempDecision.stockCode this.stockName = stockName this.currentPrice = tempDecision.currentPrice @@ -520,9 +576,11 @@ object RagService { } - private suspend fun getAiNewsScore(news: String,techSummary : String): Pair { + private suspend fun getAiNewsScore(stockName:String , news: String,techSummary : String): Pair { val prompt = """ # Role: Expert Quantitative & Sentiment Analyst + # Target Stock: [$stockName] + # Task: Evaluate [News Text] by correlating it with [Market Context]. # Input 1: [Market Context] (Standardized Scores & Price History) @@ -543,7 +601,11 @@ object RagService { - If 'Base Position' is near 100% (at 120MA), consider it a 'Safe Entry' for long-term holding. # Constraints: - - Reason: KOREAN only, max 50 chars. Explain the "Synergy" between scores and news. +- Reason: KOREAN only, max 50 chars. Explain the "Synergy" between scores and news. +1. Target Isolation: You MUST ONLY extract facts related EXACTLY to [$stockName]. +2. No Mix-up: Do NOT attribute actions of other companies (e.g., unrelated capital increases or earnings of competitors) to [$stockName]. +3. Verify Numbers: Check if [$stockName]'s YoY profit is Positive (+) or Negative (-). If Negative, you MUST reflect it as a penalty in the score. + - Output: Strictly JSON format. # JSON Output: @@ -608,6 +670,9 @@ class TradingDecision { var reason: String? = null var confidence: Double = 0.0 var newsScore : Double = 0.0 + var systemScore : Double = 0.0 + var financialScore : Double = 0.0 + var technicalScore : Double = 0.0 var investmentGrade : InvestmentGrade? = null var techSummary : String? = null var newsContext : String? = null @@ -630,6 +695,26 @@ class TradingDecision { midTermScore, longTermScore).average() + + fun summary() : String{ + return """ +$corpName[$stockName] +수익실현 가능성 : ${profitPossible()} +investmentGrade:${investmentGrade!!.name} +ultraShortScore :$ultraShortScore +shortTermScore :$shortTermScore +midTermScore :$midTermScore +longTermScore :$longTermScore +systemScore :$systemScore +technicalScore: $technicalScore +financialScore: $financialScore +newsScore: $newsScore +decision: $decision +reason: $reason + + """.trimIndent() + } + override fun toString(): String { return """ $corpName($stockName) @@ -639,12 +724,11 @@ shortTermScore :$shortTermScore midTermScore :$midTermScore longTermScore :$longTermScore decision: $decision +investmentGrade:${investmentGrade!!.name} reason: $reason confidence: $confidence 기술 분석: $techSummary - 뉴스 점수: $newsScore - """.trimIndent() } } diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 7dae10e..6053100 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -7,10 +7,10 @@ import Defines.EMBEDDING_PORT import Defines.LLM_PORT import network.TradingDecision import TradingLogStore +import analyzer.AdvancedTradeAssistant import analyzer.TechnicalAnalyzer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import getLlamaBinPath import kotlinx.coroutines.CoroutineScope @@ -25,31 +25,27 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout -import kotlinx.serialization.Serializable -import model.CandleData import model.ConfigIndex import model.ExecutionData import model.KisSession import model.RankingStock import model.RankingType import model.UnifiedBalance +import model.UnifiedStockHolding import network.DartCodeManager import network.KisAuthService import network.KisTradeService import network.KisWebSocketManager import network.RagService import network.StockUniverseLoader -import org.jetbrains.skia.ImageFilter import util.MarketUtil import java.time.LocalDate 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 { @@ -88,7 +84,7 @@ object AutoTradingManager { val nowDate = LocalDate.now(seoulZone) var checkTime = 60_000 * 3L val isTradingDay = nowDate.dayOfWeek.value in 1..5 - if (isTradingDay && now.isAfter(H08M30) && now.isBefore(H18) && !shouldShowFullWindow) { + if (isTradingDay && now.isAfter(H07M50) && now.isBefore(H18) && !shouldShowFullWindow) { shouldShowFullWindow = true SystemSleepPreventer.wakeDisplay() } else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) { @@ -102,7 +98,7 @@ object AutoTradingManager { } } -// val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> + // val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> // if (isSuccess && completeTradingDecision != null) { // // 1. 로그 저장소에 기록 (UI에서 이걸 읽음) // TradingLogStore.addLog(completeTradingDecision) @@ -188,79 +184,91 @@ object AutoTradingManager { // } // } // } -val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> - if (isSuccess && completeTradingDecision != null) { - val decision = completeTradingDecision + val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> + if (isSuccess && completeTradingDecision != null) { + val decision = completeTradingDecision - // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 - if (decision.decision == "BUY") { - val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) + // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 + if (decision.decision == "BUY") { + val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) - // AI가 이미 검증한 등급을 사용 (재계산 불필요) - val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE + // AI가 이미 검증한 등급을 사용 (재계산 불필요) + val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE - // 2. 최종 매수 실행 - val gradeRate = KisSession.config.getValues(grade.allocationRate) - val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate - val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1) - - excuteTrade( - decision = decision, - orderQty = calculatedQty.toString(), - profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide), - investmentGrade = grade - ) - } else if (decision.confidence >= 60.0) { // 아까운 종목만 재분석 - addToReanalysis(RankingStock(decision.stockCode, decision.stockName)) + // 2. 최종 매수 실행 + val gradeRate = KisSession.config.getValues(grade.allocationRate) + val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate + val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1) + TradingLogStore.addLog(decision,"BUY",decision.summary()) + excuteTrade( + decision = decision, + orderQty = calculatedQty.toString(), + profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide), + investmentGrade = grade + ) + } else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석 + addToReanalysis(RankingStock(decision.stockCode, decision.stockName)) + } } } -} - val MIN_CONFIDENCE = 60.0 // 최소 신뢰도 - var append = 0.0 fun getInvestmentGrade( ts: TradingDecision, totalScore: Double, - confidence: Double + confidence: Double, + finScore100: Double // 💡 [수정1] 컴파일 에러 방지용 파라미터 추가 ): InvestmentGrade { - // [개선] 하드코딩된 60/70 대신 사용자 설정 최소 점수를 기준으로 사용 val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) - val minConfidence = minScore // 신뢰도 하한선도 매수 기준 점수와 동기화 + val minConfidence = minScore - // 1. 최소 기준 미달 시 (관망 대상) if (totalScore < (minScore * 0.8) || confidence < minConfidence) { - return InvestmentGrade.LEVEL_1_SPECULATIVE + return InvestmentGrade.LEVEL_0_SPECULATIVE } - // 2. 패턴 점수 추출 val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0 val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0 val isOverheated = ts.analyzer?.isOverheatedStock() ?: true - // 3. [개선] 점수 구간을 5~10점씩 하향 조정하여 실제 '추천' 등급이 나오도록 보정 - val rawGrade = when { - // [A그룹] 중장기 추세가 강한 상태 - midLongAvg >= 70.0 -> { // 75 -> 70 하향 - if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // 80 -> 75 - else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 70 -> 65 + // 1. 기본 등급 산정 + var rawGrade = when { + midLongAvg >= 70.0 -> { + if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND + else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND } - - // [B그룹] 중장기 추세가 보통인 상태 - midLongAvg >= 60.0 -> { // 65 -> 60 하향 - if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 75 -> 70 - else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // 65 -> 60 + midLongAvg >= 60.0 -> { + if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_2_HIGH_RISK } - - // [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태 else -> { if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_1_SPECULATIVE } } - // 4. 단기 과열 패널티 (일괄 1단계 강등) + // 💡 [수정2] 누락되었던 우량주 눌림목 프리미엄 & 잡주 투매 회피 로직 추가 + val isHealthy = finScore100 >= 70.0 + val isPullback = midLongAvg >= 75.0 && shortAvg <= 45.0 + + if (isHealthy && isPullback) { + rawGrade = when (rawGrade) { + InvestmentGrade.LEVEL_1_SPECULATIVE, + InvestmentGrade.LEVEL_2_HIGH_RISK -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + else -> rawGrade + } + } else if (!isHealthy && isPullback) { + rawGrade = when (rawGrade) { + InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, + InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND, + InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_1_SPECULATIVE + InvestmentGrade.LEVEL_2_HIGH_RISK, + InvestmentGrade.LEVEL_1_SPECULATIVE -> InvestmentGrade.LEVEL_0_SPECULATIVE + else -> InvestmentGrade.LEVEL_0_SPECULATIVE + } + } + return if (isOverheated) { when (rawGrade) { InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND @@ -273,10 +281,11 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo } } - fun excuteTrade(decision: TradingDecision,orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { + fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { scope.launch { var basePrice = decision.currentPrice val tickSize = MarketUtil.getTickSize(basePrice) + // 등급별 가이드에 따라 매수 호가 설정 val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) var stockCode = decision.stockCode var stockName = decision.stockName @@ -284,15 +293,16 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) - .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 - println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice") - TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo") - val pRate = 0.4 - val sRate = -1.5 + .onSuccess { realOrderNo -> + // 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가 + println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") + TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo") + // 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장) + val sRate = -1.5 var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) - val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues( - ConfigIndex.PROFIT_INDEX) + tax)) + // 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기 + val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) @@ -303,7 +313,7 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo code = stockCode, name = stockName, quantity = inputQty, - profitRate = effectiveProfitRate, // 보정된 수익률 저장 + profitRate = effectiveProfitRate, stopLossRate = sRate, targetPrice = calculatedTarget, stopLossPrice = calculatedStop, @@ -311,7 +321,9 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo isDomestic = true )) syncAndExecute(realOrderNo) - TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") + + // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 + TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") } .onFailure { println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") @@ -347,16 +359,13 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice - // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) + val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05 + val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) - val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) - - // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 - val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) - - // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 + // 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) + println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") KisTradeService.postOrder( @@ -422,10 +431,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { var targetPrice = holding.currentPrice.toDouble() TradingLogStore.addAfterMarketLog( - holding.name, - holding.code, - "${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상" - ) + holding.name, + holding.code, + "${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상" + ) targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) @@ -452,6 +461,8 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo "🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] " ) } + } else { + analyzeDeepLossHoldingsAfterMarket(holding) } delay(300) // API 호출 부하 방지 } @@ -460,68 +471,113 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { -// if (isRunning()) return val now = LocalTime.now() val currentMinute = now.minute // if (now.isBefore(H16) && now.isAfter(H08M35)) { - println("resumePendingSellOrders") - balance.holdings.forEach { holding -> - if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ - println("❌ 차단 처리된 주식 : ${holding.name}") - TradingLogStore.addAnalyzer( - holding.name, - holding.code, - "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" - ) - } else { - if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { -// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ") - - // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 - var targetPrice = holding.currentPrice.toDouble() - val now = LocalTime.now() - val currentMinute = now.minute - var isBefore930 = false - if (now.hour == 9 && currentMinute < 30) { - targetPrice = targetPrice - isBefore930 = true - } else { - targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) - } - println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") - tradeService.postOrder( - stockCode = holding.code, - qty = holding.availOrderCount, - price = targetPrice.toInt().toString(), - isBuy = false, - ).onSuccess { newOrderNo -> - println("✅ [재주문 완료] ${holding.name}: $newOrderNo") - TradingLogStore.addSellLog( - holding.code, - targetPrice.toString(), - "SELL", - "🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료" - ) - }.onFailure { - TradingLogStore.addSellLog( - holding.code, - targetPrice.toString(), - "SELL", - "🎊 보유 주식 매도 주문 실패[${it.message}] " - ) - } + println("resumePendingSellOrders") + balance.holdings.forEach { holding -> + if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ + println("❌ 차단 처리된 주식 : ${holding.name}") + TradingLogStore.addAnalyzer( + holding.name, + holding.code, + "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" + ) + } else { + if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) { + var targetPrice = holding.currentPrice.toDouble() + val now = LocalTime.now() + val currentMinute = now.minute + var isBefore930 = false + if (now.hour == 9 && currentMinute < 30) { + targetPrice = targetPrice + isBefore930 = true } else { - TradingLogStore.addAnalyzer( - "보유주식[${holding.name}]", + targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) + } + println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") + tradeService.postOrder( + stockCode = holding.code, + qty = holding.availOrderCount, + price = targetPrice.toInt().toString(), + isBuy = false, + ).onSuccess { newOrderNo -> + println("✅ [보유 주식 주문 완료] ${holding.name}: $newOrderNo") + TradingLogStore.addSellLog( holding.code, - "수익률 미달 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" + targetPrice.toString(), + "SELL", + "🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료" + ) + }.onFailure { + TradingLogStore.addSellLog( + holding.code, + targetPrice.toString(), + "SELL", + "🎊 보유 주식 매도 주문 실패[${it.message}] " ) } - delay(200) // API 호출 부하 방지 + } else { + analyzeDeepLossHoldingsAfterMarket(holding) } + delay(200) // API 호출 부하 방지 } + } // } } + + private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 + val now = LocalTime.now() + val currentMinute = now.minute + if ((now.hour == 8 || now.hour == 16 || now.hour == 17)) { + val profit = holding.profitRate.toDouble() + val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다) + if (profit <= lossThreshold) { + println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...") + + // 차트 데이터 빠르게 가져오기 (일봉 위주로 큰 추세만 확인) + val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull() + + if (!dailyData.isNullOrEmpty()) { + val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData } + val currentPrice = holding.currentPrice.toDouble() + + // 1. 볼린저 밴드 하단선 (통계적 바닥) 확인 + val lowerBand = AdvancedTradeAssistant.calculateBollingerLowerBand(dailyData) + // 2. RSI 확인 (과매도 투매 상태인지) + val rsiDaily = analyzer.calculateRSI(dailyData) + // 3. 중기 추세 확인 (최근 20일 기준 10% 이상 하락했는지) + val isTrendBroken = analyzer.calculateChange(dailyData.takeLast(20)) < -10.0 + + var advice = "" + + // 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간 + if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) { + advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" + } + // 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때 + else if (isTrendBroken && currentPrice > lowerBand * 1.1) { + advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다." + } + // 🟡 [관망] 어정쩡하게 물려있는 상태 + else { + advice = "⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다." + } + + // 분석 결과를 UI 로그에 띄워 대표님이 확인할 수 있게 함 + TradingLogStore.addNotice( + "보유주식[${holding.name}]", + holding.code, + "수익률 심각($profit%) -> $advice", + ) + } + } else { + // -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김 + // TradingLogStore.addAnalyzer("보유주식[${holding.name}]", holding.code, "수익률 미달 대기중 (${profit}%)") + } + } + } + var isSystemReadyToday = false var isSystemCleanedUpToday = false private var lastRetryTime = 0L @@ -567,9 +623,9 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo var waitTime = 0.2 val H16 = LocalTime.of(16, 0) val H18 = LocalTime.of(18, 0) - val H08M35 = LocalTime.of(8, 0) + val H08M00 = LocalTime.of(8, 0) val H08M45 = LocalTime.of(8, 45) - val H08M30 = LocalTime.of(7, 50) + val H07M50 = LocalTime.of(7, 50) private fun runDiscoveryLoop(callback: TradingDecisionCallback) { discoveryJob = scope.launch { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") @@ -579,10 +635,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo currentTimeMillis = System.currentTimeMillis() lastTickTime.set(System.currentTimeMillis()) // 생존 신고 when { - now.isAfter(H18) || now.isBefore(H08M35) -> { + now.isAfter(H18) || now.isBefore(H08M00) -> { prepareMarketOpen(now) } - now.isBefore(H18) && now.isAfter(H08M35) -> { + now.isBefore(H18) && now.isAfter(H08M00) -> { waitTime = 0.2 if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { @@ -624,9 +680,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo } suspend fun prepareMarketOpen(now : LocalTime) { - if (now.isAfter(H18) || now.isBefore(H08M30)) { + if (now.isAfter(H18) || now.isBefore(H07M50)) { println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.") onMarketClosed?.invoke() + RagService.clearDailyCache() KisWebSocketManager.disconnect() BrowserManager.closeIfIdle(0) LlamaServerManager.stopAll() // AI 서버 완전 종료 @@ -634,7 +691,7 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo isSystemReadyToday = false shouldShowFullWindow = false stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) - } else if (now.isAfter(H08M30) && now.isBefore(H08M35) && !isSystemReadyToday) { + } else if (now.isAfter(H07M50) && now.isBefore(H08M00) && !isSystemReadyToday) { if (MarketUtil.canTradeToday()) { SystemSleepPreventer.wakeDisplay() shouldShowFullWindow = true @@ -1008,8 +1065,8 @@ enum class InvestmentGrade( val allocationRate: ConfigIndex, ) { LEVEL_5_STRONG_RECOMMEND( - displayName = "최상급 추천", - description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", + displayName = "최상급 스윙/가치형", + description = "중장기 추세가 완벽하며 단기 파동까지 일치하는 매우 안정적인 매수 추천", shortWeight = 1.0, midWeight = 1.0, longWeight = 1.0, @@ -1018,8 +1075,8 @@ enum class InvestmentGrade( allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE, ), LEVEL_4_BALANCED_RECOMMEND( - displayName = "균형 추천", - description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", + displayName = "우량 균형형", + description = "기본적인 펀더멘털과 중장기 추세가 양호하여 꾸준한 우상향이 기대되는 종목", shortWeight = 0.8, midWeight = 1.0, longWeight = 1.0, @@ -1028,8 +1085,8 @@ enum class InvestmentGrade( allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE, ), LEVEL_3_CAUTIOUS_RECOMMEND( - displayName = "보수적 추천", - description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", + displayName = "보수적 혼합형", + description = "중장기 지표는 양호하나 단기 변동성이 있거나, 반대로 단기 수급만 몰린 팽팽한 상태", shortWeight = 0.6, midWeight = 1.0, longWeight = 1.0, @@ -1038,8 +1095,8 @@ enum class InvestmentGrade( allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE, ), LEVEL_2_HIGH_RISK( - displayName = "고위험 추천", - description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", + displayName = "고위험 단기 모멘텀", + description = "중장기 추세는 약하지만, 뉴스나 테마로 인해 단기 수급이 강력하게 붙은 스캘핑 대상", shortWeight = 1.0, midWeight = 0.4, longWeight = 0.4, @@ -1048,14 +1105,23 @@ enum class InvestmentGrade( allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE, ), LEVEL_1_SPECULATIVE( - displayName = "순수 공격적 선택", - description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", + displayName = "순수 투기/초단타", + description = "재무 및 중장기 지표 무관, 오직 초단기 분봉과 에너지만 살아있는 극도의 투기적 진입", shortWeight = 1.0, midWeight = 0.2, longWeight = 0.2, profitGuide = ConfigIndex.GRADE_1_PROFIT, buyGuide = ConfigIndex.GRADE_1_BUY, allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE, + ), + LEVEL_0_SPECULATIVE( + displayName = "매수 금지 (관망)", + description = "최소 신뢰도(Confidence) 미달로 시스템 통과 실패", + shortWeight = 0.1, + midWeight = 0.1, + longWeight = 0.1, + profitGuide = ConfigIndex.GRADE_1_PROFIT, // 더미 데이터 + buyGuide = ConfigIndex.GRADE_1_BUY, + allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE, ) } - diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt index f7e4900..428d317 100644 --- a/src/main/kotlin/ui/TradingDecisionLog.kt +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -35,7 +35,7 @@ fun TradingDecisionLog() { val coroutineScope = rememberCoroutineScope() var searchQuery by remember { mutableStateOf("") } var selectedFilters by remember { mutableStateOf(setOf("전체")) } - val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER") + val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER","NOTICE") var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) } LaunchedEffect(AutoTradingManager.llmAnalyser) { llmAnalyser = AutoTradingManager.llmAnalyser @@ -165,6 +165,7 @@ fun TradingDecisionLog() { "RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열) "WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시) "AFTER" -> Color.Red + "NOTICE" ->Color(0xFF1E88E5) else -> Color.DarkGray }, fontWeight = FontWeight.ExtraBold