atrade/src/main/kotlin/network/RagService.kt

560 lines
23 KiB
Kotlin
Raw Normal View History

2026-03-17 10:50:13 +09:00
package network// src/main/kotlin/network/RagService.kt
2026-01-21 18:30:03 +09:00
2026-03-26 14:42:39 +09:00
import TradingLogStore
2026-01-22 16:21:18 +09:00
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.data.document.Metadata
2026-01-21 18:30:03 +09:00
import dev.langchain4j.data.segment.TextSegment
2026-03-17 10:50:13 +09:00
import dev.langchain4j.exception.InternalServerException
2026-01-21 18:30:03 +09:00
import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
2026-03-17 10:50:13 +09:00
import dev.langchain4j.service.AiServices
import dev.langchain4j.service.SystemMessage
2026-01-22 16:21:18 +09:00
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
2026-01-23 17:05:09 +09:00
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
2026-03-17 10:50:13 +09:00
import kotlinx.coroutines.Dispatchers
2026-01-22 16:21:18 +09:00
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
2026-03-18 16:01:42 +09:00
import kotlinx.coroutines.delay
2026-03-17 10:50:13 +09:00
import kotlinx.coroutines.withContext
2026-01-22 16:21:18 +09:00
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
2026-03-17 10:50:13 +09:00
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 okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
2026-01-22 16:21:18 +09:00
import org.apache.lucene.store.MMapDirectory
2026-03-17 10:50:13 +09:00
import org.slf4j.MDC.put
2026-03-27 17:45:51 +09:00
import service.AutoTradingManager
2026-02-10 15:08:52 +09:00
import service.FinancialAnalyzer
import service.InvestmentScores
2026-01-22 16:21:18 +09:00
import service.TechnicalAnalyzer
2026-01-23 17:05:09 +09:00
import service.TradingDecisionCallback
import service.UrlCacheManager
2026-01-22 16:21:18 +09:00
import java.nio.file.Paths
2026-01-21 18:30:03 +09:00
import java.time.Duration
2026-03-17 10:50:13 +09:00
import java.util.concurrent.TimeUnit
2026-01-21 18:30:03 +09:00
2026-03-16 17:07:25 +09:00
interface TradingAnalyst {
2026-03-17 10:50:13 +09:00
@SystemMessage("""
2026-03-16 17:07:25 +09:00
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
}
2026-01-21 18:30:03 +09:00
object RagService {
2026-01-23 17:05:09 +09:00
2026-01-21 18:30:03 +09:00
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel.builder()
2026-03-27 17:45:51 +09:00
.baseUrl("http://127.0.0.1:${AutoTradingManager.EMBEDDING_PORT}/v1")
2026-01-21 18:30:03 +09:00
.apiKey("unused")
.build()
private val chatModel = OpenAiChatModel.builder()
2026-03-27 17:45:51 +09:00
.baseUrl("http://127.0.0.1:${AutoTradingManager.LLM_PORT}/v1")
2026-01-21 18:30:03 +09:00
.apiKey("unused")
2026-01-23 17:05:09 +09:00
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
2026-01-21 18:30:03 +09:00
.timeout(Duration.ofSeconds(60))
2026-03-17 10:50:13 +09:00
// .frequencyPenalty(1.1)
.maxTokens(400) // 👈 루프 방지를 위해 반드시 짧게 제한!
2026-03-16 17:07:25 +09:00
// 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요
.responseFormat("json_object")
2026-01-21 18:30:03 +09:00
.build()
2026-03-17 10:50:13 +09:00
private val analyst = AiServices.builder(TradingAnalyst::class.java)
2026-03-16 17:07:25 +09:00
.chatModel(chatModel)
.build()
2026-01-22 16:21:18 +09:00
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()
2026-01-23 17:05:09 +09:00
}
fun active() {
2026-02-03 18:07:18 +09:00
// println("[Cache] Active")
2026-01-23 17:05:09 +09:00
if (UrlCacheManager.isInitialized()) return
2026-02-03 18:07:18 +09:00
// println("[Cache] initialize")
2026-01-23 17:05:09 +09:00
UrlCacheManager.initialize(embeddingStore, embeddingModel)
2026-01-22 16:21:18 +09:00
}
2026-01-23 17:05:09 +09:00
2026-03-16 17:07:25 +09:00
2026-01-21 18:30:03 +09:00
/**
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/
2026-01-23 17:05:09 +09:00
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)
}
}
2026-02-12 13:11:07 +09:00
// println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료")
2026-01-22 16:21:18 +09:00
}
2026-01-23 17:05:09 +09:00
object JsonSanitizer {
fun formatJson(raw: String): String {
2026-03-17 10:50:13 +09:00
// 실제 응답 로그 출력 (디버깅용)
println("📥 [AI Raw Response]:\n$raw")
2026-01-23 17:05:09 +09:00
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
2026-03-17 10:50:13 +09:00
val match = regex.find(raw)?.value
if (match == null) {
println("⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다.")
return "{}" // 빈 객체라도 반환하여 EOF 방지
}
return match.trim()
2026-01-23 17:05:09 +09:00
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
}
}
2026-03-27 13:38:05 +09:00
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
2026-01-22 16:21:18 +09:00
coroutineScope {
2026-02-03 18:07:18 +09:00
try {
2026-03-27 13:38:05 +09:00
var tradingDecision = TradingDecision()
2026-02-03 18:07:18 +09:00
tradingDecision.stockCode = stockCode
2026-02-19 15:47:31 +09:00
tradingDecision.analyzer = technicalAnalyzer
2026-02-13 13:49:40 +09:00
tradingDecision.currentPrice = currentPrice
2026-03-27 13:38:05 +09:00
2026-02-03 18:07:18 +09:00
var corpInfo = DartCodeManager.getCorpCode(stockCode)
corpInfo?.stockName = stockName
tradingDecision.stockName = stockName
tradingDecision.corpName = corpInfo?.cName ?: ""
2026-03-27 13:38:05 +09:00
// 1. 재무 데이터 가져오기 시간 측정
val financialStartTime = System.currentTimeMillis()
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
2026-02-03 18:07:18 +09:00
tradingDecision.financialData = financialDataDeferred.await()
2026-02-10 15:08:52 +09:00
val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "")
2026-03-27 13:38:05 +09:00
val financialDuration = System.currentTimeMillis() - financialStartTime
println("⏱️ [$stockName] 재무 분석 소요: ${financialDuration}ms")
2026-02-10 15:08:52 +09:00
if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
2026-03-27 13:38:05 +09:00
// 2. 뉴스 스크래핑 및 학습 시간 측정
val newsIngestStartTime = System.currentTimeMillis()
2026-02-10 15:08:52 +09:00
corpInfo?.let {
try {
NewsService.fetchAndIngestNews(it)
} catch (e: Exception) {}
}
2026-03-27 13:38:05 +09:00
val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime
println("⏱️ [$stockName] 뉴스 수집/인덱싱 소요: ${newsIngestDuration}ms")
2026-02-10 15:08:52 +09:00
2026-03-27 13:38:05 +09:00
// 3. 기술적 지표 계산 시간 측정
val techStartTime = System.currentTimeMillis()
2026-02-10 15:08:52 +09:00
val financialScore = FinancialAnalyzer.calculateScore(financialStmt)
val scores = technicalAnalyzer.calculateScores(financialScore)
2026-03-27 13:38:05 +09:00
val techDuration = System.currentTimeMillis() - techStartTime
println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms")
2026-03-23 10:54:54 +09:00
if (scores.avg() > 50) {
result(tradingDecision, false)
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
result(tradingDecision, false)
2026-03-27 13:38:05 +09:00
// 4. RAG 뉴스 검색 및 임베딩 시간 측정
val ragStartTime = System.currentTimeMillis()
2026-03-23 10:54:54 +09:00
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
val questionEmbedding = embeddingModel.embed(question).content()
val searchResult = embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(questionEmbedding)
.maxResults(3)
.build()
)
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
2026-03-27 13:38:05 +09:00
val ragDuration = System.currentTimeMillis() - ragStartTime
println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms")
2026-03-23 10:54:54 +09:00
result(tradingDecision, false)
2026-03-27 13:38:05 +09:00
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)", true)
result(finalDecision, true)
2026-03-23 10:54:54 +09:00
} else {
2026-03-27 13:38:05 +09:00
println("✋ [$stockName] 기술 점수 미달로 분석 중단")
2026-03-26 13:48:26 +09:00
tradingDecision.confidence = 1.0
2026-03-23 10:54:54 +09:00
result(tradingDecision, false)
}
2026-02-10 15:08:52 +09:00
} else {
2026-03-27 13:38:05 +09:00
println("🚨 [$stockName] 재무 안전벨트 미달")
2026-03-26 13:48:26 +09:00
tradingDecision.confidence = 1.0
2026-02-10 15:08:52 +09:00
result(tradingDecision, false)
}
2026-03-27 13:38:05 +09:00
} catch (e: Exception) {
2026-02-03 18:07:18 +09:00
e.printStackTrace()
}
2026-01-21 18:30:03 +09:00
}
}
2026-01-23 17:05:09 +09:00
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()
}
2026-01-22 16:21:18 +09:00
2026-01-21 18:30:03 +09:00
/**
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
*/
2026-02-20 15:21:38 +09:00
// fun askWithContext(question: String,
// corpInfo: String,
// financialData: String,
// days : List<CandleData>,
// weeks : List<CandleData>,
// monthly : List<CandleData>): String {
// val questionEmbedding = embeddingModel.embed(question).content()
// val searchResult = embeddingStore.search(
// EmbeddingSearchRequest.builder()
// .queryEmbedding(questionEmbedding)
// .maxResults(5)
// .build()
// )
// val newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
//
// // 2. 종합 분석 프롬프트 구성
// val finalPrompt = """
// <|begin_of_text|><|start_header_id|>system<|end_header_id|>
// 당신은 뉴스(심리), 재무(본질), 차트(추세)를 통합 분석하는 'AI 수석 애널리스트'입니다.
// 제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요.
//
// [데이터 세트]
// 1. 기업 기본 정보: $corpInfo
// 2. 재무 성장성: $financialData
// 3. 기술적 추세: ${monthly}, ${weeks}, ${days}
// 4. 최신 이슈(뉴스): $newsContext
//
// [분석 요청 사항]
// 1. **업계 상황**: 해당 종목이 속한 업종의 현재 전체적인 흐름을 먼저 정리하세요.
// 2. **종목 이슈 분석**: 뉴스에서 포착된 핵심 키워드와 시장의 반응을 요약하세요.
// 3. **장기/단기 전략**:
// - 장기(재무/월봉 기반): 추천 혹은 비추천 사유
// - 단기(뉴스/일봉 기반): 추천 혹은 비추천 사유
// 4. **최종 결론**: '매수/관망/매도' 의견과 그에 따른 근거를 단호하게 제시하세요.
// <|eot_id|>
// <|start_header_id|>user<|end_header_id|>
// 질문: $question
// <|eot_id|><|start_header_id|>assistant<|end_header_id|>
// """.trimIndent()
//
// val response = chatModel.chat(UserMessage.from(finalPrompt))
//// println(response)
// return response.aiMessage().text()
// }
2026-03-17 10:50:13 +09:00
private const val LLM_API_URL = "http://127.0.0.1:8080/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")
2026-03-18 16:01:42 +09:00
put("temperature", 0.1) // 💡 루프 탈출을 위해 더 과감하게 설정
put("top_p", 0.85) // 💡 추가
put("top_k", 40) // 💡 추가 (서버에서 지원할 경우)
// put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화
// put("presence_penalty", 0.5)
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
put("max_tokens", 400)
2026-03-17 10:50:13 +09:00
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)
}
}
2026-01-22 16:21:18 +09:00
2026-03-17 10:50:13 +09:00
// 💡 복잡한 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()
2026-03-18 16:01:42 +09:00
2026-01-22 16:21:18 +09:00
suspend fun decideTrading(
stockName: String,
2026-03-18 16:01:42 +09:00
scores: InvestmentScores,
financialStmt: FinancialStatement,
2026-01-23 17:05:09 +09:00
tempDecision: TradingDecision
2026-01-22 16:21:18 +09:00
): TradingDecision? {
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
var retryCount = 0
val maxRetries = 2
while (retryCount <= maxRetries) {
// 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400)
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
// 2. 뉴스 유무에 따른 동적 데이터 섹션 구성
val newsDataSection = if (validNews != null) {
"3. News Context: $validNews"
} else {
"3. News Context: No significant news available. Rely on financials."
}
val prompt = """
2026-03-16 17:07:25 +09:00
# Task: Senior AI Investment Analyst
2026-03-18 16:01:42 +09:00
Your goal is to provide a final trading decision based on STRICT data analysis.
# Data (SOURCE OF TRUTH)
2026-03-16 17:07:25 +09:00
1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm})
2026-03-18 16:01:42 +09:00
2026-03-16 17:07:25 +09:00
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)}%
2026-03-18 16:01:42 +09:00
2026-03-17 10:50:13 +09:00
$newsDataSection
2026-03-16 17:07:25 +09:00
2026-03-18 16:01:42 +09:00
# 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.
# 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)
2026-03-16 17:07:25 +09:00
{
2026-03-18 16:01:42 +09:00
"ultraShortScore": ${scores.ultraShort},
"shortTermScore": ${scores.shortTerm},
"midTermScore": ${scores.midTerm},
"longTermScore": ${scores.longTerm},
2026-03-17 10:50:13 +09:00
"decision": "HOLD",
2026-03-18 16:01:42 +09:00
"reason": "10자 이상 50자 이내의 한국어 분석 결과",
"confidence": 0
2026-03-16 17:07:25 +09:00
}
2026-03-18 16:01:42 +09:00
2026-03-16 17:07:25 +09:00
""".trimIndent()
2026-03-18 16:01:42 +09:00
try {
val rawResponse = callLlamaWithSchema(prompt)
// 환각 및 루프 검사
println("rawResponse $rawResponse")
2026-01-22 16:21:18 +09:00
2026-01-23 17:05:09 +09:00
2026-03-18 16:01:42 +09:00
val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim()
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString<TradingDecision>(sanitized)
2026-01-23 17:05:09 +09:00
2026-03-18 16:01:42 +09:00
// 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영)
val reasonLen = decision.reason?.length ?: 0
val isReasonValid = reasonLen in 5..60 // 약간의 마진 허용
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
// 점수가 보존되었는지 확인 (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++
}
2026-01-22 16:21:18 +09:00
2026-03-18 16:01:42 +09:00
} catch (e: Exception) {
println("❌ [파싱 오류] ${e.message} - 재시도 시도 중... (${retryCount + 1})")
retryCount++
delay(500)
2026-01-23 17:05:09 +09:00
}
2026-03-18 16:01:42 +09:00
}
// 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환
println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.")
return TradingDecision().apply {
this.stockName = stockName
this.decision = "HOLD"
this.reason = "AI 분석 지연으로 인한 자동 관망 처리"
2026-01-22 16:21:18 +09:00
}
}
2026-03-18 16:01:42 +09:00
2026-01-22 16:21:18 +09:00
}
2026-02-20 15:21:38 +09:00
2026-01-22 16:21:18 +09:00
@Serializable
class TradingDecision {
2026-02-03 18:07:18 +09:00
var corpName : String = ""
var stockName : String = ""
2026-01-23 17:05:09 +09:00
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
val longTermScore: Double = 0.0
2026-02-03 18:07:18 +09:00
// [추가] 화면 전환용 종목명
var currentPrice: Double = 0.0
2026-01-23 17:05:09 +09:00
var stockCode: String = ""
2026-01-22 16:21:18 +09:00
var decision: String? = null
var reason: String? = null
2026-01-23 17:05:09 +09:00
var confidence: Double = 0.0
2026-01-22 16:21:18 +09:00
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
2026-02-19 15:47:31 +09:00
var analyzer : TechnicalAnalyzer? = null
2026-02-04 14:52:09 +09:00
fun shortPossible() =
listOf<Double>(ultraShortScore,
shortTermScore).average()
2026-01-23 17:05:09 +09:00
fun profitPossible() =
listOf<Double>(ultraShortScore,
2026-02-03 18:07:18 +09:00
shortTermScore,
midTermScore,
longTermScore).average()
fun safePossible() =
listOf<Double>(
midTermScore,
longTermScore).average()
2026-01-23 17:05:09 +09:00
2026-01-22 16:21:18 +09:00
override fun toString(): String {
return """
2026-02-03 18:07:18 +09:00
$corpName($stockName)
2026-01-23 17:05:09 +09:00
수익실현 가능성 : ${profitPossible()}
2026-01-22 17:56:31 +09:00
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
2026-01-22 16:21:18 +09:00
decision: $decision
reason: $reason
confidence: $confidence
2026-01-23 17:05:09 +09:00
기술 분석: $techSummary
뉴스: $newsContext
재무재표: $financialData
2026-01-22 16:21:18 +09:00
""".trimIndent()
2026-01-21 18:30:03 +09:00
}
}