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 import analyzer.InvestmentScores import analyzer.ScalpingSignalModel import analyzer.TechnicalAnalyzer import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore import dev.langchain4j.data.document.Metadata import dev.langchain4j.data.segment.TextSegment import dev.langchain4j.exception.InternalServerException import dev.langchain4j.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel import dev.langchain4j.service.AiServices import dev.langchain4j.service.SystemMessage import dev.langchain4j.store.embedding.EmbeddingSearchRequest import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import model.ConfigIndex import model.KisSession import model.RankingStock import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.apache.lucene.store.MMapDirectory import org.slf4j.MDC.put import service.AutoTradingManager import service.InvestmentGrade import service.TradingDecisionCallback import service.UrlCacheManager import java.nio.file.Paths import java.time.Duration 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 { // @SystemMessage(""" // You are a Senior Stock Analyst. // Analyze the data and provide a decision in JSON format. // You must respond ONLY with a valid JSON object. // """) // fun analyzeStock(@dev.langchain4j.service.UserMessage prompt: String): TradingDecision //} object RagService { val isSafetyBeltStockCodes = ConcurrentHashMap.newKeySet() // (매일 아침 8시 30분 시스템 초기화 시 호출해주어야 함) fun clearDailyCache() { isSafetyBeltStockCodes.clear() println("🧹 [System] 일일 재무 미달 캐시 초기화 완료") } // 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정 private val embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("http://127.0.0.1:${EMBEDDING_PORT}/v1") .apiKey("unused") .build() private val chatModel = OpenAiChatModel.builder() .baseUrl("http://127.0.0.1:${LLM_PORT}/v1") .apiKey("unused") .temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도 .timeout(Duration.ofSeconds(60)) // .frequencyPenalty(1.1) .maxTokens(400) // 👈 루프 방지를 위해 반드시 짧게 제한! // 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요 .responseFormat("json_object") .build() // private val analyst = AiServices.builder(TradingAnalyst::class.java) // .chatModel(chatModel) // .build() private val embeddingStore: LuceneEmbeddingStore by lazy { val path = Paths.get("db/lucene_idx") // FSDirectory.open(path)도 가능하지만, 64bit 시스템(Mac)에선 MMapDirectory가 가장 빠릅니다. val directory = MMapDirectory(path) // 제공해주신 소스의 Builder 사용 LuceneEmbeddingStore.builder() .directory(directory) .build() } fun active() { // println("[Cache] Active") if (UrlCacheManager.isInitialized()) return // println("[Cache] initialize") UrlCacheManager.initialize(embeddingStore, embeddingModel) } /** * 텍스트를 임베딩하여 H2 DB에 저장합니다. */ fun ingestWithChunking( text: String, newsLink: String = "", pubDate: String = "", stcokName: String, corpCode: String, corpName: String, stockCode: String ) { val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정 // 1. 문단 단위로 먼저 분리 val paragraphs = text.split(Regex("\n\n+")) val chunks = mutableListOf() var currentChunk = StringBuilder() for (para in paragraphs) { // 현재 청크에 문단을 더했을 때 제한을 넘으면 지금까지의 내용을 확정 if (currentChunk.length + para.length > MAX_CHUNK_SIZE && currentChunk.isNotEmpty()) { chunks.add(currentChunk.toString().trim()) currentChunk = StringBuilder() } currentChunk.append(para).append("\n\n") // 문단 하나 자체가 너무 긴 경우 글자 수로 강제 분할 if (currentChunk.length > MAX_CHUNK_SIZE) { val longPara = currentChunk.toString() longPara.chunked(MAX_CHUNK_SIZE).forEach { chunks.add(it.trim()) } currentChunk = StringBuilder() } } if (currentChunk.isNotEmpty()) chunks.add(currentChunk.toString().trim()) // 2. 쪼개진 각 청크를 루씬에 개별 임베딩하여 저장 chunks.forEachIndexed { index, chunk -> if (chunk.length > 10) { // 너무 짧은 노이즈 제외 val metadata = Metadata() metadata.put("link", newsLink) metadata.put("date", pubDate) metadata.put("chunk_idx", index) // 순서 정보 유지 metadata.put("stcokName",stcokName) metadata.put("corpCode",corpCode) metadata.put("corpName",corpName) metadata.put("stockCode",stockCode) val segment = TextSegment.from(chunk, metadata) val embedding = embeddingModel.embed(segment).content() embeddingStore.add(embedding, segment) } } // println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료") } object JsonSanitizer { fun formatJson(raw: String): String { // 실제 응답 로그 출력 (디버깅용) println("📥 [AI Raw Response]:\n$raw") val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL) val match = regex.find(raw)?.value if (match == null) { println("⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다.") return "{}" // 빈 객체라도 반환하여 EOF 방지 } return match.trim() .removePrefix("```json") .removePrefix("```") .removeSuffix("```") .trim() } } private fun isRecentNews(dateStr: String?, maxDays: Long = 3): Boolean { if (dateStr.isNullOrBlank()) return false return try { // 네이버 뉴스 OpenAPI 기본 포맷: "Mon, 06 Apr 2026 12:00:00 +0900" val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) val pubDate = ZonedDateTime.parse(dateStr, formatter) val now = ZonedDateTime.now() // 뉴스가 미래로 표기된 경우도 대비하여 절대값 처리 Math.abs(ChronoUnit.DAYS.between(pubDate, now)) <= maxDays } catch (e: Exception) { // 다른 날짜 포맷(예: "yyyy.MM.dd")으로 들어오는 경우를 위한 Fallback try { val fallbackFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ENGLISH) val pubDate = ZonedDateTime.parse("$dateStr 00:00 +0900", fallbackFormatter) Math.abs(ChronoUnit.DAYS.between(pubDate, ZonedDateTime.now())) <= maxDays } catch (e2: Exception) { false // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도 } } } private fun isVeryRecentNews(dateStr: String?, maxHours: Long = 1): Boolean { if (dateStr.isNullOrBlank()) return false return try { val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) val pubDate = ZonedDateTime.parse(dateStr, formatter) val now = ZonedDateTime.now() // 현재 시간과 뉴스 발행 시간의 차이를 시간 단위로 계산 val hoursDiff = Math.abs(ChronoUnit.HOURS.between(pubDate, now)) hoursDiff < maxHours // 1시간 미만이면 true } catch (e: Exception) { false } } suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) { val totalStartTime = System.currentTimeMillis() coroutineScope { try { val tradingDecision = TradingDecision().apply { this.stockCode = stockCode this.analyzer = technicalAnalyzer 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) val financialStmt = FinancialMapper.mapRawTextToStatement(financialData) val finDuration = System.currentTimeMillis() - finStartTime println("financialStmt ${FinancialAnalyzer.toString(financialStmt)} isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(financialStmt)}") // [2단계] 기술적 지표 및 과열 체크 val techStartTime = System.currentTimeMillis() val financialScore = FinancialAnalyzer.calculateScore(financialStmt) val scores = technicalAnalyzer.calculateScores(financialScore) tradingDecision.signalModel = technicalAnalyzer.generateComprehensiveSignal() val techDuration = System.currentTimeMillis() - techStartTime println("techSignal.compositeScore ${tradingDecision.signalModel}") 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) 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 } // [3단계] 뉴스 RAG 및 AI 분석 (가장 오래 걸림) val ragStartTime = System.currentTimeMillis() // 1시간 이내 뉴스 존재 여부 확인 후 동적 스크래핑 checkAndFetchRecentNews(stockName, stockCode) val question = "$stockName 실적 및 향후 전망" val questionEmbedding = embeddingModel.embed(question).content() val finalSearchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) .maxResults(10) // 최신 뉴스 3개 적정 .minScore(0.2) .build() ) // 3. 검색된 내용을 하나의 문자열로 합쳐서 전달 tradingDecision.newsContext = finalSearchResult.matches().distinct() // 중복 제거 .take(4) // 10개에서 4개로 축소 .joinToString("\n\n") { it.embedded().text() } val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision) val ragAiDuration = System.currentTimeMillis() - ragStartTime // [4단계] 최종 로그 기록 val totalDuration = System.currentTimeMillis() - totalStartTime val detailLog = "재무(${finDuration}ms), 기술(${techDuration}ms), 뉴스/AI(${ragAiDuration}ms), 전체(${totalDuration}ms)" TradingLogStore.addAnalyzer(stockName, stockCode, detailLog, true) println("$stockName[$stockCode] $detailLog") result(finalDecision, true) } catch (e: Exception) { println("❌ [$stockName] 분석 실패: ${e.message}") } } } private suspend fun checkAndFetchRecentNews(stockName: String, stockCode: String) { val question = "$stockName 실적 전망 및 최근 이슈" val questionEmbedding = embeddingModel.embed(question).content() // 1. 벡터 DB에서 해당 종목의 뉴스 검색 val searchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) .maxResults(10) .minScore(0.2) .build() ) // 2. 검색된 뉴스 중 1시간 이내(Very Recent) 데이터가 있는지 확인 val hasHotNews = searchResult.matches().any { match -> val pubDate = match.embedded().metadata().getString("date") isVeryRecentNews(pubDate, maxHours = 1) } // 3. 최신 뉴스가 없다면 네이버 API 및 Playwright 스크래핑 가동 if (!hasHotNews) { println("🌐 [$stockName] 최근 1시간 내 분석된 뉴스가 없습니다. 실시간 스크래핑을 시작합니다.") val corpInfo = DartCodeManager.getCorpCode(stockCode) corpInfo?.let { try { // NewsService에서 오늘자 뉴스를 가져와 인덱싱 수행 NewsService.fetchAndIngestNews(it) } catch (e: Exception) { println("❌ [$stockName] 뉴스 업데이트 실패: ${e.message}") } } } else { println("✅ [$stockName] 최근 1시간 내 기사가 DB에 존재하여 스크래핑을 건너뜁니다.") } } // 시간 기록용 헬퍼 함수 private fun logTime(name: String, status: String, stepMs: Long, totalMs: Long) { println("⏱️ [$name] $status - 단계: ${stepMs}ms / 누적: ${totalMs}ms") } fun isUrlAlreadyIndexed(url: String): Boolean { // 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성 val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url) // 2. 검색 요청 생성 (벡터 유사도와 상관없이 필터 조건에 맞는 것 1개만 찾음) // 주의: 인터페이스에 따라 더미 벡터(0,0,...)가 필요할 수 있습니다. val searchRequest = EmbeddingSearchRequest.builder() .filter(filter) .maxResults(1) .build() val result = embeddingStore.search(searchRequest) // 결과가 비어있지 않다면 이미 저장된 URL입니다. return result.matches().isNotEmpty() } private fun LLM_API_URL() = "http://127.0.0.1:$LLM_PORT/v1/chat/completions" private suspend fun callLlamaWithSchema(prompt: String): String { val jsonMediaType = "application/json; charset=utf-8".toMediaType() // 문자열 치환 대신 안전한 JSON 객체 빌더 사용 val requestBodyJson = buildJsonObject { put("model", "local-model") put("temperature", 0.1) // 💡 루프 탈출을 위해 더 과감하게 설정 put("top_p", 0.85) // 💡 추가 put("top_k", 40) // 💡 추가 (서버에서 지원할 경우) // put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화 // put("presence_penalty", 0.5) put("max_tokens", 200) putJsonArray("messages") { addJsonObject { put("role", "system") put("content", "You are a helpful AI financial analyst. You must output responses ONLY in valid JSON format.") } addJsonObject { put("role", "user") put("content", prompt) } } // 💡 복잡한 json_schema를 지우고, 단순히 JSON 형식으로만 내보내라고 지시합니다. putJsonObject("response_format") { put("type", "json_object") } }.toString() println("requestBodyJson =>> $requestBodyJson") val request = Request.Builder() .url(LLM_API_URL()) .post(requestBodyJson.toRequestBody(jsonMediaType)) .build() return kotlinx.coroutines.Dispatchers.IO.let { kotlinx.coroutines.withContext(it) { httpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) throw Exception("LLM API Error: ${response.code} ${response.message}") val responseBody = response.body?.string() ?: "{}" val json = Json.parseToJsonElement(responseBody).jsonObject json["choices"]?.jsonArray?.get(0)?.jsonObject?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content ?: "{}" } } } } private val httpClient = OkHttpClient.Builder() .connectTimeout(60, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .build() suspend fun decideTrading( stockName: String, scores: InvestmentScores, financialStmt: FinancialStatement, tempDecision: TradingDecision ): TradingDecision? { val totalStartTime = System.currentTimeMillis() // 전체 시작 시간 // 1-1. 재무 점수 산출 시간 측정 val finStartTime = System.currentTimeMillis() val finScore100 = FinancialAnalyzer.calculateScore(financialStmt).toDouble() val finDuration = System.currentTimeMillis() - finStartTime // 1-2. 기술 분석 및 리포트 생성 시간 측정 val techStartTime = System.currentTimeMillis() val techScore100 = tempDecision.signalModel?.compositeScore?.toDouble() ?: 0.0 val isOverheated = tempDecision.analyzer?.isOverheatedStock() ?: false tempDecision.techSummary = tempDecision.analyzer?.generateComprehensiveReport(finScore100.toInt()) val techDuration = System.currentTimeMillis() - techStartTime // 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간) val newsStartTime = System.currentTimeMillis() val (newsScore100, newsReason) = tempDecision.newsContext?.let { getAiNewsScore(stockName,it, tempDecision.techSummary ?: "") } ?: (50.0 to "참조 뉴스 없음") val newsDuration = System.currentTimeMillis() - newsStartTime // 1-4. 시스템 및 가중치 합성 시간 측정 val synthStartTime = System.currentTimeMillis() val sysScore100 = calculateSystemPoint(scores) * 4.0 // 가중치 합성 (Tech 35% : Fin 25% : News 20% : Sys 20%) var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.25) + (newsScore100 * 0.30) + (sysScore100 * 0.20) // var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.35) + (newsScore100 * 0.20) + (sysScore100 * 0.20) // 보너스 및 패널티 로직 if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0 if (techScore100 >= 90.0 && finScore100 >= 50.0) finalConfidence += 5.0 if (isOverheated) finalConfidence *= 0.85 val totalScore = (scores.ultraShort + scores.shortTerm + scores.midTerm + scores.longTerm) / 4.0 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" finalReason = "📉 뉴스 악재 감지: $newsReason" } isOverheated && finalConfidence < 85.0 -> { finalDecision = "HOLD" finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한" } finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_0_SPECULATIVE -> { finalDecision = "BUY" finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수 | $assistantReason" } finalConfidence < 40.0 -> { finalDecision = "SELL" finalReason = "⚠️ 종합 지표 악화로 인한 비중 축소 권장" } else -> { finalDecision = "HOLD" finalReason = "⏳ 지표 중립 또는 확신 부족 (신뢰도: ${String.format("%.1f", finalConfidence)})" } } val totalDuration = System.currentTimeMillis() - totalStartTime // 성능 분석 로그 출력 (CSV 형태로 출력하여 나중에 엑셀 분석 가능) 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 this.techSummary = tempDecision.techSummary this.ultraShortScore = scores.ultraShort.toDouble() this.shortTermScore = scores.shortTerm.toDouble() this.midTermScore = scores.midTerm.toDouble() this.longTermScore = scores.longTerm.toDouble() this.reason = finalReason this.decision = finalDecision this.confidence = finalConfidence this.investmentGrade = grade this.newsScore = newsScore100 this.newsContext = tempDecision.newsContext this.financialData = tempDecision.financialData }.apply { if (confidence > 50.0) { println(this.toString()) } } } 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) $techSummary # Input 2: [News Text] (Latest Headlines & Content) $news # Evaluation Logic (Internal Reasoning): 1. Value Gap: Compare 'Financial Score' with 'Base Position'. (e.g., High Score + Low Position = Strong Buy) 2. Momentum Catalyst: Check if News justifies the 'Volume Intensity' and 'Weekly Breakout'. 3. Sentiment Weight: - Positive: Earnings surprise, Contract win, Turnaround, Buyback. (+10~30) - Negative: Deficit, Lawsuit, Capital increase (Dilution). (-20~40) # Operational Instructions: - If the news mentions specific profit figures (e.g., "72B KRW"), award a "Profit Bonus" even without YoY comparison. - 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. 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: { "score": [0.0-100.0], "reason": "[KOREAN_REASON]" } """.trimIndent() return try { val raw = callLlamaWithSchema(prompt) println("getAiNewsScore $raw") val json = Json { ignoreUnknownKeys = true }.parseToJsonElement(raw).jsonObject val score = json["score"]?.jsonPrimitive?.double ?: 50.0 val reason = json["reason"]?.jsonPrimitive?.content ?: "뉴스 분석 완료" score to reason } catch (e: Exception) { 50.0 to "뉴스 분석 오류 발생 (중립 처리)" } } // 재무 점수 계산 (Max 40) private fun calculateFinancialPoint(fs: FinancialStatement): Double { var p = 0.0 // 영업이익 흑자면 25점, 적자여도 성장 중이면 10점 p += if (fs.isOperatingProfitPositive) 25.0 else (if (fs.operatingProfitGrowth > 30) 10.0 else 0.0) // ROE 10% 기준 비례 배분 (Max 10) p += (fs.roe / 15.0 * 10.0).coerceIn(0.0, 10.0) // 부채비율 100% 이하면 5점 만점 p += if (fs.debtRatio <= 100.0) 5.0 else (150.0 - fs.debtRatio).coerceAtLeast(0.0) / 10.0 return p } private fun calculateSystemPoint(s: InvestmentScores): Double { val midLongAvg = (s.midTerm + s.longTerm) / 2.0 val base = (midLongAvg / 100.0 * 15.0) + (s.ultraShort / 100.0 * 10.0) // 정배열 보너스: 초단기 > 단기 > 중기 점수 순서일 때 가점 val alignmentBonus = if (s.ultraShort > s.shortTerm && s.shortTerm > s.midTerm) 3.0 else 0.0 return (base + alignmentBonus).coerceIn(0.0, 25.0) } } @Serializable class TradingDecision { var corpName : String = "" var stockName : String = "" var ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지) var shortTermScore: Double = 0.0 // 단기 (일봉/뉴스) var midTermScore: Double = 0.0 // 중기 (주봉/재무) var longTermScore: Double = 0.0 // [추가] 화면 전환용 종목명 var currentPrice: Double = 0.0 var stockCode: String = "" var decision: String? = null 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 var financialData : String? = null var analyzer : TechnicalAnalyzer? = null var signalModel : ScalpingSignalModel? = null fun shortPossible() = listOf(ultraShortScore, shortTermScore).average() fun profitPossible() = listOf(ultraShortScore, shortTermScore, midTermScore, longTermScore).average() fun safePossible() = listOf( 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) 수익실현 가능성 : ${profitPossible()} ultraShortScore :$ultraShortScore shortTermScore :$shortTermScore midTermScore :$midTermScore longTermScore :$longTermScore decision: $decision investmentGrade:${investmentGrade!!.name} reason: $reason confidence: $confidence 기술 분석: $techSummary 뉴스 점수: $newsScore """.trimIndent() } } //재무재표: $financialData //뉴스: $newsContext