atrade/src/main/kotlin/network/RagService.kt
2026-04-16 15:48:23 +09:00

680 lines
30 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.EmbeddingSearchResult
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 model.TradingDecision
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<String>()
// (매일 아침 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<String>()
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()
var finalSearchResult : EmbeddingSearchResult<TextSegment>? = null
try {
finalSearchResult = embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(questionEmbedding)
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode))
.maxResults(10) // 최신 뉴스 3개 적정
.minScore(0.2)
.build()
)
} catch (e: Exception) {}
// 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에서 해당 종목의 뉴스 검색
var searchResult : EmbeddingSearchResult<TextSegment>? = null
try {
searchResult = embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(questionEmbedding)
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode))
.maxResults(10)
.minScore(0.2)
.build()
)
} catch (e: Exception) {
}
// 2. 검색된 뉴스 중 1시간 이내(Very Recent) 데이터가 있는지 확인
val hasHotNews = searchResult?.matches()?.any { match ->
val pubDate = match.embedded().metadata().getString("date")
isVeryRecentNews(pubDate, maxHours = 1)
} ?: false
// 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<Double, String> {
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 startIndex = raw.indexOf("{")
val endIndex = raw.lastIndexOf("}")
// '{' 부터 '}' 까지만 정확하게 잘라냄
val sanitizedRaw = if (startIndex != -1 && endIndex != -1) {
raw.substring(startIndex, endIndex + 1)
} else {
raw // 중괄호를 못 찾았을 경우 대비 (기본 fallback)
}
// 정제된 문자열로 JSON 파싱
val json = Json { ignoreUnknownKeys = true }.parseToJsonElement(sanitizedRaw).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)
}
}