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

230 lines
9.3 KiB
Kotlin
Raw Normal View History

2026-01-21 18:30:03 +09:00
// src/main/kotlin/network/RagService.kt
2026-01-22 16:21:18 +09:00
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.data.document.Metadata
import dev.langchain4j.data.message.UserMessage
2026-01-21 18:30:03 +09:00
import dev.langchain4j.data.segment.TextSegment
import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
2026-01-22 16:21:18 +09:00
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.CandleData
import network.DartCodeManager
import network.KisTradeService
import network.NewsService
import org.apache.lucene.store.MMapDirectory
2026-01-21 18:30:03 +09:00
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
2026-01-22 16:21:18 +09:00
import service.TechnicalAnalyzer
import java.nio.file.Paths
2026-01-21 18:30:03 +09:00
import java.time.Duration
object RagService {
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://127.0.0.1:8081/v1")
.apiKey("unused")
.build()
private val chatModel = OpenAiChatModel.builder()
.baseUrl("http://127.0.0.1:8080/v1")
.apiKey("unused")
.timeout(Duration.ofSeconds(60))
.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-21 18:30:03 +09:00
/**
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/
2026-01-22 16:21:18 +09:00
fun ingest(text: String, newsLink: String = "", pubDate: String = "") {
// 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성
val metadata = Metadata()
metadata.put("link", newsLink)
metadata.put("date", pubDate)
// TextSegment.from(text, metadata) 팩토리 메서드 활용
val segment = TextSegment.from(text, metadata)
val embedding = embeddingModel.embed(segment).content()
// LuceneEmbeddingStore.add(Embedding, TextSegment) 호출
embeddingStore.add(embedding, segment)
println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...")
}
suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) {
// 1. 10분간의 데이터 가져오기 (API 호출)
coroutineScope {
var tradingDecision : TradingDecision = TradingDecision()
2026-01-22 17:56:31 +09:00
val corpCode = DartCodeManager.getCorpCode(stockCode)
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) }
2026-01-22 16:21:18 +09:00
tradingDecision.financialData = financialDataDeferred.await()
result(tradingDecision.toString(),false)
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport()
result(tradingDecision.toString(),false)
val question = "$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() }
result(tradingDecision.toString(),false)
decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: ""))
2026-01-21 18:30:03 +09:00
}
}
2026-01-22 16:21:18 +09:00
2026-01-21 18:30:03 +09:00
/**
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
*/
2026-01-22 16:21:18 +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() }
2026-01-21 18:30:03 +09:00
2026-01-22 16:21:18 +09:00
// 2. 종합 분석 프롬프트 구성
2026-01-21 18:59:55 +09:00
val finalPrompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
2026-01-22 16:21:18 +09:00
당신은 뉴스(심리), 재무(본질), 차트(추세) 통합 분석하는 'AI 수석 애널리스트'입니다.
제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요.
[데이터 세트]
1. 기업 기본 정보: $corpInfo
2. 재무 성장성: $financialData
3. 기술적 추세: ${monthly}, ${weeks}, ${days}
4. 최신 이슈(뉴스): $newsContext
2026-01-21 18:59:55 +09:00
2026-01-22 16:21:18 +09:00
[분석 요청 사항]
1. **업계 상황**: 해당 종목이 속한 업종의 현재 전체적인 흐름을 먼저 정리하세요.
2. **종목 이슈 분석**: 뉴스에서 포착된 핵심 키워드와 시장의 반응을 요약하세요.
3. **장기/단기 전략**:
- 장기(재무/월봉 기반): 추천 혹은 비추천 사유
- 단기(뉴스/일봉 기반): 추천 혹은 비추천 사유
4. **최종 결론**: '매수/관망/매도' 의견과 그에 따른 근거를 단호하게 제시하세요.
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
질문: $question
2026-01-21 18:59:55 +09:00
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent()
2026-01-21 18:30:03 +09:00
2026-01-22 16:21:18 +09:00
val response = chatModel.chat(UserMessage.from(finalPrompt))
println(response)
return response.aiMessage().text()
}
suspend fun decideTrading(
stockName: String,
techSummary: String,
newsContext: String,
financialData: String
): TradingDecision? {
val prompt = """
2026-01-22 17:56:31 +09:00
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 하나를 결정하세요.
2026-01-22 16:21:18 +09:00
2026-01-22 17:56:31 +09:00
[데이터 요약]
- 종목: $stockName
2026-01-22 16:21:18 +09:00
$techSummary
2026-01-22 17:56:31 +09:00
- 기업/재무: $financialData
- 시장 심리: $newsContext
2026-01-22 16:21:18 +09:00
2026-01-22 17:56:31 +09:00
[스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80 이상.
2. 단기: 일봉 이평선 정배열 3 변동률 양수일 70 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익) 동반 상승 75 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[응답 형식]
반드시 아래 JSON 형식으로만 답변하십시오:
2026-01-22 16:21:18 +09:00
{
2026-01-22 17:56:31 +09:00
"ultraShortScore": (숫자),
"shortTermScore": (숫자),
"midTermScore": (숫자),
"longTermScore": (숫자),
2026-01-22 16:21:18 +09:00
"decision": "BUY" | "SELL" | "HOLD",
"reason": "결정적 근거 한 줄",
"confidence": 0~100
}
2026-01-22 17:56:31 +09:00
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
2026-01-22 16:21:18 +09:00
""".trimIndent()
val response = chatModel.chat(UserMessage.from(prompt))
val jsonResponse = response.aiMessage().text()
// JSON 파싱 (Kotlinx Serialization 활용)
return try {
println(jsonResponse)
val decision = Json.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = financialData
decision.newsContext = newsContext
decision.techSummary = techSummary
decision
} catch (e: Exception) {
null
}
}
}
@Serializable
class TradingDecision {
2026-01-22 17:56:31 +09:00
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
val shortTermScore: Int = 0 // 단기 (일봉/뉴스)
val midTermScore: Int = 0 // 중기 (주봉/재무)
val longTermScore: Int = 0
2026-01-22 16:21:18 +09:00
var decision: String? = null
var reason: String? = null
var confidence: Int = 0
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
override fun toString(): String {
return """
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
techSummary: $techSummary
newsContext: $newsContext
financialData: $financialData
""".trimIndent()
2026-01-21 18:30:03 +09:00
}
}