빨라져라
This commit is contained in:
@@ -3,6 +3,11 @@ package network// src/main/kotlin/network/RagService.kt
|
||||
import Defines.EMBEDDING_PORT
|
||||
import Defines.LLM_PORT
|
||||
import TradingLogStore
|
||||
import analyzer.FinancialAnalyzer
|
||||
import analyzer.FinancialMapper
|
||||
import analyzer.FinancialStatement
|
||||
import analyzer.InvestmentScores
|
||||
import analyzer.TechnicalAnalyzer
|
||||
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
|
||||
import dev.langchain4j.data.document.Metadata
|
||||
import dev.langchain4j.data.segment.TextSegment
|
||||
@@ -23,6 +28,7 @@ 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
|
||||
@@ -39,9 +45,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.lucene.store.MMapDirectory
|
||||
import org.slf4j.MDC.put
|
||||
import service.AutoTradingManager
|
||||
import service.FinancialAnalyzer
|
||||
import service.InvestmentScores
|
||||
import service.TechnicalAnalyzer
|
||||
import service.InvestmentGrade
|
||||
import service.TradingDecisionCallback
|
||||
import service.UrlCacheManager
|
||||
import java.nio.file.Paths
|
||||
@@ -206,140 +210,140 @@ object RagService {
|
||||
}
|
||||
}
|
||||
|
||||
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() // 전체 시작 시간
|
||||
val totalStartTime = System.currentTimeMillis()
|
||||
|
||||
coroutineScope {
|
||||
try {
|
||||
var tradingDecision = TradingDecision()
|
||||
tradingDecision.stockCode = stockCode
|
||||
tradingDecision.analyzer = technicalAnalyzer
|
||||
tradingDecision.currentPrice = currentPrice
|
||||
|
||||
var corpInfo = DartCodeManager.getCorpCode(stockCode)
|
||||
corpInfo?.stockName = stockName
|
||||
tradingDecision.stockName = stockName
|
||||
tradingDecision.corpName = corpInfo?.cName ?: ""
|
||||
|
||||
// 1. 재무 데이터 가져오기 시간 측정
|
||||
val financialStartTime = System.currentTimeMillis()
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
|
||||
tradingDecision.financialData = financialDataDeferred.await()
|
||||
val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "")
|
||||
val financialDuration = System.currentTimeMillis() - financialStartTime
|
||||
println("⏱️ [$stockName] 재무 분석 소요: ${financialDuration}ms")
|
||||
|
||||
if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
||||
// 3. 기술적 지표 계산 시간 측정
|
||||
val techStartTime = System.currentTimeMillis()
|
||||
val financialScore = FinancialAnalyzer.calculateScore(financialStmt)
|
||||
val scores = technicalAnalyzer.calculateScores(financialScore)
|
||||
val techDuration = System.currentTimeMillis() - techStartTime
|
||||
println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms")
|
||||
val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||
if (scores.avg() > (guideLine.times(0.50))) {
|
||||
// 2. 뉴스 스크래핑 및 학습 시간 측정
|
||||
val ragStartTime = System.currentTimeMillis()
|
||||
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
||||
val questionEmbedding = embeddingModel.embed(question).content()
|
||||
|
||||
// --- 💡 [수정됨] 2. 해당 주식의 최신 뉴스 존재 여부 확인 (최대 10개) ---
|
||||
val preSearchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(questionEmbedding)
|
||||
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 해당 종목만 필터링
|
||||
.maxResults(10)
|
||||
.minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
|
||||
// 검색된 청크들 중 최근 3일 이내의 날짜를 가진 데이터가 하나라도 있는지 확인
|
||||
val hasRecentData = preSearchResult.matches().any { match ->
|
||||
val pubDate = match.embedded().metadata().getString("date")
|
||||
isRecentNews(pubDate, maxDays = 1)
|
||||
}
|
||||
|
||||
// --- 💡 [수정됨] 3. 최신 데이터가 없을 때만 브라우저 스크래핑(Playwright) 실행 ---
|
||||
val newsIngestStartTime = System.currentTimeMillis()
|
||||
if (!hasRecentData) {
|
||||
println("🌐 [$stockName] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다.")
|
||||
corpInfo?.let {
|
||||
try {
|
||||
NewsService.fetchAndIngestNews(it)
|
||||
} catch (e: Exception) {
|
||||
println("❌ [$stockName] 뉴스 스크래핑 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("✅ [$stockName] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다.")
|
||||
}
|
||||
val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime
|
||||
println("⏱️ [$stockName] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration}ms")
|
||||
|
||||
result(tradingDecision, false)
|
||||
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
|
||||
result(tradingDecision, false)
|
||||
|
||||
// --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 ---
|
||||
// (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다)
|
||||
val finalSearchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(questionEmbedding)
|
||||
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 교차 오염 방지를 위해 필터 필수
|
||||
.maxResults(3)
|
||||
.minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
|
||||
.build()
|
||||
)
|
||||
|
||||
println("🔎 [$stockName] RAG 검색된 문서 개수: ${finalSearchResult.matches().size}개")
|
||||
finalSearchResult.matches().forEach { match ->
|
||||
println("📊 [RAG Score: ${match.score()}] 본문: ${match.embedded().text().replace("\n", " ").take(50)}...")
|
||||
}
|
||||
|
||||
tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n") { it.embedded().text() }
|
||||
val ragDuration = System.currentTimeMillis() - ragStartTime
|
||||
println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms")
|
||||
|
||||
result(tradingDecision, false)
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "${FinancialAnalyzer.toString(financialStmt)}${scores.toString()}", true)
|
||||
|
||||
// 5. AI 최종 결정(LLM 호출) 시간 측정
|
||||
val aiDecisionStartTime = System.currentTimeMillis()
|
||||
val finalDecision = decideTrading(stockCode, scores, financialStmt, tradingDecision)
|
||||
val aiDecisionDuration = System.currentTimeMillis() - aiDecisionStartTime
|
||||
println("⏱️ [$stockName] AI 최종 판단 소요: ${aiDecisionDuration}ms")
|
||||
|
||||
val totalDuration = System.currentTimeMillis() - totalStartTime
|
||||
println("✅ [$stockName] 전체 분석 완료 총 소요: ${totalDuration}ms")
|
||||
|
||||
// 상세 로그 남기기
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "분석시간 상세: 재무(${financialDuration}ms), 뉴스(${newsIngestDuration}ms), RAG(${ragDuration}ms), AI(${aiDecisionDuration}ms), 전체 분석 완료(${totalDuration}ms)", true)
|
||||
|
||||
result(finalDecision, true)
|
||||
} else {
|
||||
println("✋ [$stockName] 기술 점수 미달로 분석 중단 ${scores.toString()}")
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "기술 점수 미달로 분석 중단")
|
||||
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt)) {
|
||||
TradingLogStore.addLog(tradingDecision,"WATCH","우량주로 판단되나 거래량 혹은 최근 거래 점수 미달로 재분석 대상에 추가")
|
||||
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
||||
}
|
||||
tradingDecision.confidence = 1.0
|
||||
result(tradingDecision, false)
|
||||
}
|
||||
} else {
|
||||
println("🚨 [$stockName] ${FinancialAnalyzer.toString(financialStmt)} 재무 안전벨트 미달")
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "재무 안전벨트 미달로 분석 중단 ${FinancialAnalyzer.toString(financialStmt)}")
|
||||
tradingDecision.confidence = 1.0
|
||||
result(tradingDecision, false)
|
||||
val tradingDecision = TradingDecision().apply {
|
||||
this.stockCode = stockCode
|
||||
this.analyzer = technicalAnalyzer
|
||||
this.currentPrice = currentPrice
|
||||
}
|
||||
|
||||
// [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)
|
||||
val techSignal = technicalAnalyzer.generateComprehensiveSignal()
|
||||
val techDuration = System.currentTimeMillis() - techStartTime
|
||||
println("techSignal.compositeScore ${techSignal.compositeScore}")
|
||||
|
||||
if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
||||
logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime)
|
||||
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false)
|
||||
return@coroutineScope
|
||||
}
|
||||
|
||||
if (techSignal.compositeScore < 50) {
|
||||
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
|
||||
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().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) {
|
||||
e.printStackTrace()
|
||||
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)
|
||||
@@ -371,7 +375,7 @@ object RagService {
|
||||
// put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화
|
||||
// put("presence_penalty", 0.5)
|
||||
|
||||
put("max_tokens", 400)
|
||||
put("max_tokens", 200)
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
@@ -418,128 +422,171 @@ object RagService {
|
||||
financialStmt: FinancialStatement,
|
||||
tempDecision: TradingDecision
|
||||
): TradingDecision? {
|
||||
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
|
||||
|
||||
var retryCount = 0
|
||||
val maxRetries = 2
|
||||
// 1-1. 재무 점수 산출 시간 측정
|
||||
val finStartTime = System.currentTimeMillis()
|
||||
val finScore100 = FinancialAnalyzer.calculateScore(financialStmt).toDouble()
|
||||
val finDuration = System.currentTimeMillis() - finStartTime
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
// 1-2. 기술 분석 및 리포트 생성 시간 측정
|
||||
val techStartTime = System.currentTimeMillis()
|
||||
val techSignal = tempDecision.analyzer?.generateComprehensiveSignal()
|
||||
val techScore100 = techSignal?.compositeScore?.toDouble() ?: 0.0
|
||||
val isOverheated = tempDecision.analyzer?.isOverheatedStock() ?: false
|
||||
tempDecision.techSummary = tempDecision.analyzer?.generateComprehensiveReport(finScore100.toInt())
|
||||
val techDuration = System.currentTimeMillis() - techStartTime
|
||||
|
||||
// 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단
|
||||
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400)
|
||||
// 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간)
|
||||
val newsStartTime = System.currentTimeMillis()
|
||||
val (newsScore100, newsReason) = tempDecision.newsContext?.let {
|
||||
getAiNewsScore(it, tempDecision.techSummary ?: "")
|
||||
} ?: (50.0 to "참조 뉴스 없음")
|
||||
val newsDuration = System.currentTimeMillis() - newsStartTime
|
||||
|
||||
// 2. 뉴스 유무에 따른 동적 데이터 섹션 구성
|
||||
val newsDataSection = if (validNews != null) {
|
||||
"4. News Context: $validNews"
|
||||
} else {
|
||||
"4. News Context: No significant news available. Rely on financials."
|
||||
// 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
|
||||
val grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence)
|
||||
val synthDuration = System.currentTimeMillis() - synthStartTime
|
||||
|
||||
// 5. 최종 결정 및 사유 정리
|
||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||
var finalDecision = "HOLD"
|
||||
var finalReason = ""
|
||||
|
||||
when {
|
||||
newsScore100 < 30.0 -> {
|
||||
finalDecision = "HOLD"
|
||||
finalReason = "📉 뉴스 악재 감지: $newsReason"
|
||||
}
|
||||
|
||||
|
||||
val prompt = """
|
||||
# Task: Senior AI Investment Analyst
|
||||
|
||||
Your goal is to provide a final trading decision based on STRICT data analysis.
|
||||
|
||||
|
||||
# Data (SOURCE OF TRUTH)
|
||||
1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm})
|
||||
|
||||
2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}%
|
||||
|
||||
3. Technical Analysis Summary: ${tempDecision.techSummary ?: "No technical summary available."}
|
||||
|
||||
$newsDataSection
|
||||
|
||||
|
||||
|
||||
# Step-by-Step Analysis Logic
|
||||
|
||||
1. Financial Review: First, evaluate the 'Financials' section for long-term stability.
|
||||
|
||||
2. News Verification: Second, check 'News Context' (if available) for immediate market sentiment or specific issues.
|
||||
|
||||
3. Synthesis: Finalize the 'decision' (BUY, SELL, HOLD) by combining the Financials and News analysis.
|
||||
|
||||
4. Confidence: Assign a confidence score (0-100) based on how clearly the data points to the decision.
|
||||
|
||||
# Confidence Scoring Guide (CRITICAL)
|
||||
Assign the 'confidence' score based on these rules:
|
||||
- 80-100: When Financials are strong AND News Context clearly supports the trend.
|
||||
- 50-79: When Financials are stable but News is neutral or missing.
|
||||
- 10-49: When Financials and News contradict each other.
|
||||
- 1-9: Reserved ONLY for extreme data corruption.
|
||||
- NEVER output 0 unless the data is completely unreadable. Even a weak guess should be at least 10.
|
||||
|
||||
# Strict Constraints
|
||||
|
||||
- SCORE INTEGRITY: You MUST copy the 'System Scores' into the output JSON exactly as provided. NO TRANSFORMATION.
|
||||
|
||||
- REASON LENGTH: The "reason" field MUST be written in KOREAN and MUST be between 10 to 50 characters.
|
||||
|
||||
- JSON ONLY: Output ONLY a valid JSON object. No markdown, no pre-text, no post-text.
|
||||
|
||||
|
||||
# Output JSON Structure (STRICT NAMES)
|
||||
|
||||
{
|
||||
"ultraShortScore": ${scores.ultraShort},
|
||||
"shortTermScore": ${scores.shortTerm},
|
||||
"midTermScore": ${scores.midTerm},
|
||||
"longTermScore": ${scores.longTerm},
|
||||
"decision": "HOLD",
|
||||
"reason": "10자 이상 50자 이내의 한국어 분석 결과",
|
||||
"confidence": 0
|
||||
}
|
||||
|
||||
""".trimIndent()
|
||||
try {
|
||||
val rawResponse = callLlamaWithSchema(prompt)
|
||||
// 환각 및 루프 검사
|
||||
println("rawResponse $rawResponse")
|
||||
|
||||
|
||||
val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim()
|
||||
|
||||
val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString<TradingDecision>(sanitized)
|
||||
|
||||
// 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영)
|
||||
val reasonLen = decision.reason?.length ?: 0
|
||||
val isReasonValid = reasonLen in 5..60 // 약간의 마진 허용
|
||||
|
||||
// 점수가 보존되었는지 확인 (Scalping 점수 대조)
|
||||
val isScorePreserved = decision.ultraShortScore == scores.ultraShort.toDouble()
|
||||
|
||||
if (isReasonValid && isScorePreserved) {
|
||||
return decision.apply {
|
||||
this.stockCode = tempDecision.stockCode
|
||||
this.stockName = tempDecision.stockName
|
||||
this.corpName = tempDecision.corpName
|
||||
this.financialData = tempDecision.financialData
|
||||
this.newsContext = tempDecision.newsContext
|
||||
}
|
||||
} else {
|
||||
println("⚠️ [검증 실패] 사유길이($reasonLen) 또는 점수보존($isScorePreserved) 실패. 재시도 합니다.")
|
||||
retryCount++
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ [파싱 오류] ${e.message} - 재시도 시도 중... (${retryCount + 1})")
|
||||
retryCount++
|
||||
delay(500)
|
||||
isOverheated && finalConfidence < 85.0 -> {
|
||||
finalDecision = "HOLD"
|
||||
finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한"
|
||||
}
|
||||
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_1_SPECULATIVE -> {
|
||||
finalDecision = "BUY"
|
||||
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수"
|
||||
}
|
||||
finalConfidence < 40.0 -> {
|
||||
finalDecision = "SELL"
|
||||
finalReason = "⚠️ 종합 지표 악화로 인한 비중 축소 권장"
|
||||
}
|
||||
else -> {
|
||||
finalDecision = "HOLD"
|
||||
finalReason = "⏳ 지표 중립 또는 확신 부족 (신뢰도: ${String.format("%.1f", finalConfidence)})"
|
||||
}
|
||||
}
|
||||
// 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환
|
||||
println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.")
|
||||
|
||||
val totalDuration = System.currentTimeMillis() - totalStartTime
|
||||
|
||||
// 성능 분석 로그 출력 (CSV 형태로 출력하여 나중에 엑셀 분석 가능)
|
||||
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
||||
|
||||
return TradingDecision().apply {
|
||||
this.stockCode = tempDecision.stockCode
|
||||
this.stockName = stockName
|
||||
this.decision = "HOLD"
|
||||
this.reason = "AI 분석 지연으로 인한 자동 관망 처리"
|
||||
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(news: String,techSummary : String): Pair<Double, String> {
|
||||
val prompt = """
|
||||
# Role: Expert Quantitative & Sentiment Analyst
|
||||
# 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.
|
||||
- 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -548,16 +595,18 @@ Assign the 'confidence' score based on these rules:
|
||||
class TradingDecision {
|
||||
var corpName : String = ""
|
||||
var stockName : String = ""
|
||||
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
|
||||
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
|
||||
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
|
||||
val longTermScore: Double = 0.0
|
||||
var 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 investmentGrade : InvestmentGrade? = null
|
||||
var techSummary : String? = null
|
||||
var newsContext : String? = null
|
||||
var financialData : String? = null
|
||||
@@ -590,88 +639,13 @@ decision: $decision
|
||||
reason: $reason
|
||||
confidence: $confidence
|
||||
기술 분석: $techSummary
|
||||
뉴스: $newsContext
|
||||
재무재표: $financialData
|
||||
|
||||
뉴스 점수: $newsScore
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
//재무재표: $financialData
|
||||
//뉴스: $newsContext
|
||||
|
||||
|
||||
|
||||
object FinancialMapper {
|
||||
/**
|
||||
* 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환
|
||||
*/
|
||||
fun mapRawTextToStatement(rawText: String): FinancialStatement {
|
||||
if (rawText.isBlank()) {
|
||||
return FinancialStatement()
|
||||
}
|
||||
// println(rawText)
|
||||
val currentValues = extractYearlyValues(rawText, "당기")
|
||||
val previousValues = extractYearlyValues(rawText, "전기")
|
||||
|
||||
// 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100
|
||||
val opCurrent = currentValues["영업이익"] ?: 0.0
|
||||
val opPrevious = previousValues["영업이익"] ?: 0.0
|
||||
val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0
|
||||
|
||||
// 2. 당기순이익 증가율
|
||||
val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0
|
||||
val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0
|
||||
val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0
|
||||
|
||||
// 3. ROE: 당기순이익 / 당기 자본총계 * 100
|
||||
val equityCurrent = currentValues["자본총계"] ?: 1.0
|
||||
val roe = (niCurrent / equityCurrent) * 100
|
||||
|
||||
// 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100
|
||||
val debtCurrent = currentValues["부채총계"] ?: 0.0
|
||||
val debtRatio = (debtCurrent / equityCurrent) * 100
|
||||
|
||||
// 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100
|
||||
val currentAssets = currentValues["유동자산"] ?: 0.0
|
||||
val currentLiabilities = currentValues["유동부채"] ?: 1.0
|
||||
val quickRatio = (currentAssets / currentLiabilities) * 100
|
||||
|
||||
return FinancialStatement(
|
||||
operatingProfitGrowth = opGrowth,
|
||||
netIncomeGrowth = niGrowth,
|
||||
roe = roe,
|
||||
debtRatio = debtRatio,
|
||||
quickRatio = quickRatio,
|
||||
isOperatingProfitPositive = opCurrent > 0,
|
||||
isNetIncomePositive = niCurrent > 0
|
||||
).apply {
|
||||
println("당기순이익: ${niCurrent} , isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(this)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
|
||||
val result = mutableMapOf<String, Double>()
|
||||
|
||||
// 핵심 수정: 항목명 뒤에 (당기) 또는 (전기)가 오고, 그 직후의 숫자(마이너스, 쉼표 포함)를 캡처
|
||||
// 쉼표나 공백으로 끝나는 지점까지 찾습니다.
|
||||
val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""")
|
||||
|
||||
regex.findAll(text).forEach { match ->
|
||||
val key = match.groupValues[1].trim()
|
||||
// 숫자 내 쉼표 제거 후 Double 변환
|
||||
val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
result[key] = rawValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FinancialStatement(
|
||||
val revenueGrowth: Double = 0.0, // 매출액 증가율
|
||||
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
|
||||
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
|
||||
val roe: Double = 0.0, // ROE
|
||||
val debtRatio: Double = 0.0, // 부채비율
|
||||
val quickRatio: Double = 0.0, // 당좌비율
|
||||
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
|
||||
val isNetIncomePositive: Boolean = false
|
||||
)
|
||||
Reference in New Issue
Block a user