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
|
|
|
}
|
|
|
|
|
}
|