677 lines
30 KiB
Kotlin
677 lines
30 KiB
Kotlin
package network// src/main/kotlin/network/RagService.kt
|
|
|
|
import Defines.EMBEDDING_PORT
|
|
import Defines.LLM_PORT
|
|
import TradingLogStore
|
|
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.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.FinancialAnalyzer
|
|
import service.InvestmentScores
|
|
import service.TechnicalAnalyzer
|
|
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.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 {
|
|
|
|
// 임베딩 모델 (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 // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도
|
|
}
|
|
}
|
|
}
|
|
|
|
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
|
|
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)
|
|
}
|
|
} catch (e: Exception) {
|
|
e.printStackTrace()
|
|
}
|
|
}
|
|
}
|
|
|
|
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", 400)
|
|
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? {
|
|
|
|
var retryCount = 0
|
|
val maxRetries = 2
|
|
|
|
while (retryCount <= maxRetries) {
|
|
|
|
// 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단
|
|
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400)
|
|
|
|
// 2. 뉴스 유무에 따른 동적 데이터 섹션 구성
|
|
val newsDataSection = if (validNews != null) {
|
|
"4. News Context: $validNews"
|
|
} else {
|
|
"4. News Context: No significant news available. Rely on financials."
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
// 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환
|
|
println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.")
|
|
return TradingDecision().apply {
|
|
this.stockName = stockName
|
|
this.decision = "HOLD"
|
|
this.reason = "AI 분석 지연으로 인한 자동 관망 처리"
|
|
}
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
@Serializable
|
|
class TradingDecision {
|
|
var corpName : String = ""
|
|
var stockName : String = ""
|
|
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
|
|
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
|
|
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
|
|
val longTermScore: Double = 0.0
|
|
// [추가] 화면 전환용 종목명
|
|
var currentPrice: Double = 0.0
|
|
var stockCode: String = ""
|
|
var decision: String? = null
|
|
var reason: String? = null
|
|
var confidence: Double = 0.0
|
|
var techSummary : String? = null
|
|
var newsContext : String? = null
|
|
var financialData : String? = null
|
|
var analyzer : TechnicalAnalyzer? = null
|
|
|
|
fun shortPossible() =
|
|
listOf<Double>(ultraShortScore,
|
|
shortTermScore).average()
|
|
|
|
fun profitPossible() =
|
|
listOf<Double>(ultraShortScore,
|
|
shortTermScore,
|
|
midTermScore,
|
|
longTermScore).average()
|
|
|
|
fun safePossible() =
|
|
listOf<Double>(
|
|
midTermScore,
|
|
longTermScore).average()
|
|
|
|
override fun toString(): String {
|
|
return """
|
|
$corpName($stockName)
|
|
수익실현 가능성 : ${profitPossible()}
|
|
ultraShortScore :$ultraShortScore
|
|
shortTermScore :$shortTermScore
|
|
midTermScore :$midTermScore
|
|
longTermScore :$longTermScore
|
|
decision: $decision
|
|
reason: $reason
|
|
confidence: $confidence
|
|
기술 분석: $techSummary
|
|
뉴스: $newsContext
|
|
재무재표: $financialData
|
|
""".trimIndent()
|
|
}
|
|
}
|
|
|
|
|
|
|
|
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
|
|
) |