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

680 lines
30 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-27 17:54:21 +09:00
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
2026-03-26 14:42:39 +09:00
import TradingLogStore
2026-04-08 14:18:09 +09:00
import analyzer.AdvancedTradeAssistant
2026-04-07 17:32:21 +09:00
import analyzer.FinancialAnalyzer
import analyzer.FinancialMapper
import analyzer.FinancialStatement
import analyzer.InvestmentScores
2026-04-07 18:07:18 +09:00
import analyzer.ScalpingSignalModel
2026-04-07 17:32:21 +09:00
import analyzer.TechnicalAnalyzer
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-04-15 10:53:40 +09:00
import dev.langchain4j.store.embedding.EmbeddingSearchResult
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
2026-04-07 17:32:21 +09:00
import kotlinx.serialization.json.double
2026-03-17 10:50:13 +09:00
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
2026-04-01 15:03:11 +09:00
import model.ConfigIndex
import model.KisSession
2026-04-02 14:05:14 +09:00
import model.RankingStock
2026-04-16 15:48:23 +09:00
import model.TradingDecision
2026-03-17 10:50:13 +09:00
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-04-07 17:32:21 +09:00
import service.InvestmentGrade
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-04-06 15:07:14 +09:00
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
2026-04-08 14:18:09 +09:00
import java.util.concurrent.ConcurrentHashMap
2026-03-17 10:50:13 +09:00
import java.util.concurrent.TimeUnit
2026-01-21 18:30:03 +09:00
2026-04-06 15:07:14 +09:00
//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
//}
2026-03-16 17:07:25 +09:00
2026-01-21 18:30:03 +09:00
object RagService {
2026-04-08 14:18:09 +09:00
val isSafetyBeltStockCodes = ConcurrentHashMap.newKeySet<String>()
// (매일 아침 8시 30분 시스템 초기화 시 호출해주어야 함)
fun clearDailyCache() {
isSafetyBeltStockCodes.clear()
println("🧹 [System] 일일 재무 미달 캐시 초기화 완료")
}
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:54:21 +09:00
.baseUrl("http://127.0.0.1:${EMBEDDING_PORT}/v1")
2026-01-21 18:30:03 +09:00
.apiKey("unused")
.build()
private val chatModel = OpenAiChatModel.builder()
2026-03-27 17:54:21 +09:00
.baseUrl("http://127.0.0.1:${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-04-06 15:07:14 +09:00
// private val analyst = AiServices.builder(TradingAnalyst::class.java)
// .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-04-06 15:07:14 +09:00
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 // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도
}
}
}
2026-04-07 17:32:21 +09:00
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
}
}
2026-03-27 13:38:05 +09:00
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
2026-04-07 17:32:21 +09:00
val totalStartTime = System.currentTimeMillis()
2026-03-27 13:38:05 +09:00
2026-01-22 16:21:18 +09:00
coroutineScope {
2026-02-03 18:07:18 +09:00
try {
2026-04-07 17:32:21 +09:00
val tradingDecision = TradingDecision().apply {
this.stockCode = stockCode
this.analyzer = technicalAnalyzer
this.currentPrice = currentPrice
}
2026-04-08 14:18:09 +09:00
if (isSafetyBeltStockCodes.contains(stockCode)) {
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
return@coroutineScope
}
2026-04-07 17:32:21 +09:00
// [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)
2026-04-07 18:07:18 +09:00
tradingDecision.signalModel = technicalAnalyzer.generateComprehensiveSignal()
2026-04-07 17:32:21 +09:00
val techDuration = System.currentTimeMillis() - techStartTime
2026-04-07 18:07:18 +09:00
println("techSignal.compositeScore ${tradingDecision.signalModel}")
2026-04-07 17:32:21 +09:00
if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime)
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false)
2026-04-08 14:18:09 +09:00
isSafetyBeltStockCodes.add(stockCode)
2026-04-07 17:32:21 +09:00
return@coroutineScope
}
2026-04-07 18:07:18 +09:00
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
2026-04-07 17:32:21 +09:00
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
2026-04-08 14:18:09 +09:00
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)
}
2026-04-07 17:32:21 +09:00
return@coroutineScope
2026-02-10 15:08:52 +09:00
}
2026-04-07 17:32:21 +09:00
// [3단계] 뉴스 RAG 및 AI 분석 (가장 오래 걸림)
val ragStartTime = System.currentTimeMillis()
// 1시간 이내 뉴스 존재 여부 확인 후 동적 스크래핑
checkAndFetchRecentNews(stockName, stockCode)
val question = "$stockName 실적 및 향후 전망"
val questionEmbedding = embeddingModel.embed(question).content()
2026-04-15 10:53:40 +09:00
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) {}
2026-04-07 17:32:21 +09:00
// 3. 검색된 내용을 하나의 문자열로 합쳐서 전달
2026-04-15 10:53:40 +09:00
tradingDecision.newsContext = finalSearchResult?.matches()?.distinct() // 중복 제거
?.take(4) // 10개에서 4개로 축소
?.joinToString("\n\n") {
2026-04-08 14:18:09 +09:00
it.embedded().text()
}
2026-04-07 17:32:21 +09:00
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)
2026-03-27 13:38:05 +09:00
} catch (e: Exception) {
2026-04-07 17:32:21 +09:00
println("❌ [$stockName] 분석 실패: ${e.message}")
}
}
}
private suspend fun checkAndFetchRecentNews(stockName: String, stockCode: String) {
val question = "$stockName 실적 전망 및 최근 이슈"
val questionEmbedding = embeddingModel.embed(question).content()
// 1. 벡터 DB에서 해당 종목의 뉴스 검색
2026-04-15 10:53:40 +09:00
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) {
}
2026-04-07 17:32:21 +09:00
// 2. 검색된 뉴스 중 1시간 이내(Very Recent) 데이터가 있는지 확인
2026-04-15 10:53:40 +09:00
val hasHotNews = searchResult?.matches()?.any { match ->
2026-04-07 17:32:21 +09:00
val pubDate = match.embedded().metadata().getString("date")
isVeryRecentNews(pubDate, maxHours = 1)
2026-04-15 10:53:40 +09:00
} ?: false
2026-04-07 17:32:21 +09:00
// 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}")
}
2026-02-03 18:07:18 +09:00
}
2026-04-07 17:32:21 +09:00
} else {
println("✅ [$stockName] 최근 1시간 내 기사가 DB에 존재하여 스크래핑을 건너뜁니다.")
2026-01-21 18:30:03 +09:00
}
}
2026-04-07 17:32:21 +09:00
// 시간 기록용 헬퍼 함수
private fun logTime(name: String, status: String, stepMs: Long, totalMs: Long) {
println("⏱️ [$name] $status - 단계: ${stepMs}ms / 누적: ${totalMs}ms")
}
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-03-30 13:41:26 +09:00
private fun LLM_API_URL() = "http://127.0.0.1:$LLM_PORT/v1/chat/completions"
2026-03-17 10:50:13 +09:00
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-04-07 17:32:21 +09:00
put("max_tokens", 200)
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()
2026-03-30 13:41:26 +09:00
.url(LLM_API_URL())
2026-03-17 10:50:13 +09:00
.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-04-07 17:32:21 +09:00
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
2026-03-17 10:50:13 +09:00
2026-04-07 17:32:21 +09:00
// 1-1. 재무 점수 산출 시간 측정
val finStartTime = System.currentTimeMillis()
val finScore100 = FinancialAnalyzer.calculateScore(financialStmt).toDouble()
val finDuration = System.currentTimeMillis() - finStartTime
// 1-2. 기술 분석 및 리포트 생성 시간 측정
val techStartTime = System.currentTimeMillis()
2026-04-07 18:07:18 +09:00
val techScore100 = tempDecision.signalModel?.compositeScore?.toDouble() ?: 0.0
2026-04-07 17:32:21 +09:00
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 {
2026-04-08 14:18:09 +09:00
getAiNewsScore(stockName,it, tempDecision.techSummary ?: "")
2026-04-07 17:32:21 +09:00
} ?: (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
2026-04-08 14:18:09 +09:00
tempDecision.ultraShortScore = scores.ultraShort.toDouble()
tempDecision.shortTermScore = scores.shortTerm.toDouble()
tempDecision.midTermScore = scores.midTerm.toDouble()
tempDecision.longTermScore = scores.longTerm.toDouble()
2026-04-07 17:32:21 +09:00
// 5. 최종 결정 및 사유 정리
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
var finalDecision = "HOLD"
var finalReason = ""
2026-04-08 14:18:09 +09:00
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
2026-04-07 17:32:21 +09:00
when {
newsScore100 < 30.0 -> {
finalDecision = "HOLD"
finalReason = "📉 뉴스 악재 감지: $newsReason"
2026-03-18 16:01:42 +09:00
}
2026-04-07 17:32:21 +09:00
isOverheated && finalConfidence < 85.0 -> {
finalDecision = "HOLD"
finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한"
}
2026-04-08 14:18:09 +09:00
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_0_SPECULATIVE -> {
2026-04-07 17:32:21 +09:00
finalDecision = "BUY"
2026-04-08 14:18:09 +09:00
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수 | $assistantReason"
2026-04-07 17:32:21 +09:00
}
finalConfidence < 40.0 -> {
finalDecision = "SELL"
finalReason = "⚠️ 종합 지표 악화로 인한 비중 축소 권장"
}
else -> {
finalDecision = "HOLD"
finalReason = "⏳ 지표 중립 또는 확신 부족 (신뢰도: ${String.format("%.1f", finalConfidence)})"
}
}
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
val totalDuration = System.currentTimeMillis() - totalStartTime
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
// 성능 분석 로그 출력 (CSV 형태로 출력하여 나중에 엑셀 분석 가능)
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
2026-01-22 16:21:18 +09:00
2026-04-07 17:32:21 +09:00
return TradingDecision().apply {
2026-04-08 14:18:09 +09:00
this.technicalScore = techScore100
this.financialScore = finScore100
this.systemScore = sysScore100
2026-04-07 17:32:21 +09:00
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())
}
}
}
2026-01-23 17:05:09 +09:00
2026-03-17 10:50:13 +09:00
2026-04-08 14:18:09 +09:00
private suspend fun getAiNewsScore(stockName:String , news: String,techSummary : String): Pair<Double, String> {
2026-04-07 17:32:21 +09:00
val prompt = """
# Role: Expert Quantitative & Sentiment Analyst
2026-04-08 14:18:09 +09:00
# Target Stock: [$stockName]
2026-04-07 17:32:21 +09:00
# 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:
2026-04-08 14:18:09 +09:00
- 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.
2026-04-07 17:32:21 +09:00
- Output: Strictly JSON format.
# JSON Output:
{
"score": [0.0-100.0],
"reason": "[KOREAN_REASON]"
}
""".trimIndent()
2026-01-23 17:05:09 +09:00
2026-04-07 17:32:21 +09:00
return try {
val raw = callLlamaWithSchema(prompt)
println("getAiNewsScore $raw")
2026-04-09 13:17:52 +09:00
// 💡 [핵심 해결책] 마크다운 블록 및 앞뒤 쓸데없는 텍스트 완벽 차단
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
2026-03-17 10:50:13 +09:00
2026-04-07 17:32:21 +09:00
val score = json["score"]?.jsonPrimitive?.double ?: 50.0
val reason = json["reason"]?.jsonPrimitive?.content ?: "뉴스 분석 완료"
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
score to reason
} catch (e: Exception) {
50.0 to "뉴스 분석 오류 발생 (중립 처리)"
}
}
2026-01-22 16:21:18 +09:00
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
// 재무 점수 계산 (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
}
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
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)
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