This commit is contained in:
2026-01-23 17:05:09 +09:00
parent ac50737ea8
commit d89d793efa
16 changed files with 665 additions and 256 deletions
+12 -4
View File
@@ -8,8 +8,15 @@ import java.io.File
import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
data class CorpInfo(
var cCode : String = "",
var cName : String = "",
var stockCode : String = "",
var stockName : String = "",
)
object DartCodeManager {
private val corpCodeMap = mutableMapOf<String, String>()
private val corpCodeMap = mutableMapOf<String, CorpInfo>()
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
private fun saveXmlDebugFile(xmlBytes: ByteArray) {
@@ -63,10 +70,11 @@ object DartCodeManager {
val element = nodeList.item(i) as org.w3c.dom.Element
val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: ""
val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: ""
println("stockCode: $stockCode , corpCode: $corpCode")
val corpName = element.getElementsByTagName("corp_name").item(0)?.textContent ?: ""
// println("[$corpName]stockCode: $stockCode , corpCode: $corpCode")
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가
if (stockCode.isNotEmpty()) {
corpCodeMap[stockCode] = corpCode
corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode)
}
}
}
@@ -74,7 +82,7 @@ object DartCodeManager {
/**
* 6자리 종목코드로 8자리 법인코드 반환
*/
fun getCorpCode(stockCode: String): String? {
fun getCorpCode(stockCode: String): CorpInfo? {
// 1. 직접 매칭 시도
corpCodeMap[stockCode]?.let { return it }
+2 -2
View File
@@ -220,12 +220,12 @@ object KisTradeService {
val body = response.body<JsonObject>()
val output2 = body["output2"]?.jsonArray
println("output2 ${output2}")
val candles = output2?.map { element ->
val obj = element.jsonObject
CandleData(
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
stck_prpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
stck_prpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",
@@ -1,67 +0,0 @@
package network
import java.io.File
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
object LlamaServerManager {
// 포트별로 프로세스를 관리합니다.
private val processes = ConcurrentHashMap<Int, Process>()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
Runtime.getRuntime().addShutdownHook(Thread {
stopAll()
})
}
fun startServer(binPath: String, modelPath: String, port: Int, nGpuLayers: Int = 99) {
// 이미 해당 포트에서 실행 중이거나 모델 경로가 비었으면 무시합니다.
if (processes.containsKey(port) || modelPath.isBlank()) return
val command = listOf(
binPath,
"-m", modelPath,
"--port", port.toString(),
"-c", if (port == 8081) "512" else "4096", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
"-ngl", nGpuLayers.toString(),
"-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장
"--embedding" // 임베딩 기능을 활성화합니다.
)
scope.launch {
try {
val pb = ProcessBuilder(command)
pb.redirectErrorStream(true)
File(binPath).setExecutable(true)
val process = pb.start()
processes[port] = process
println("✅ AI 서버 시작 시도 (Port: $port, Model: ${File(modelPath).name})")
val reader = BufferedReader(InputStreamReader(process.inputStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
// 로그 출력 (디버깅용)
println("[Server $port] $line")
if (line?.contains("server is listening") == true) {
println("🚀 AI 서버 준비 완료 (Port: $port)")
}
}
} catch (e: Exception) {
println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}")
processes.remove(port)
}
}
}
fun stopAll() {
processes.forEach { (port, process) ->
process.destroy()
println("🛑 AI 서버 종료 (Port: $port)")
}
processes.clear()
}
}
+39 -51
View File
@@ -15,65 +15,56 @@ import io.ktor.client.request.parameter
import io.ktor.http.ContentType.Application.Json
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import model.CorpInfo
import model.DartFinancialResponse
import model.NaverNewsResponse
import service.DynamicNewsScraper
import service.SafeScraper
import service.UrlCacheManager
object NewsService {
private val client = HttpClient<CIOEngineConfig>(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true })
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
suspend fun fetchAndIngestNews(query: String) {
suspend fun fetchAndIngestNews(corpInfo: CorpInfo) {
val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요
val clientSecret = "DODCxb1M4Z"
try {
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
parameter("query", query)
parameter("display", 10) // 최근 10개 뉴스
parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret)
}.body()
response.items.forEach { item ->
// HTML 태그 제거 및 텍스트 정제
val cleanTitle = item.title.replace(Regex("<[^>]*>"), "")
val cleanDesc = item.description.replace(Regex("<[^>]*>"), "")
val fullText = "[$cleanTitle] $cleanDesc"
println(fullText)
// RAG 서비스에 학습(Ingest) 시키기
RagService.ingest(
text = fullText,
newsLink = item.originallink,
pubDate = item.pubDate
)
var qlist = listOf<String>("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}")
qlist.forEach { query ->
try {
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
parameter("query", query)
parameter("display", 3) // 최근 10개 뉴스
parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
header("X-Naver-Client-Id", clientId)
header("X-Naver-Client-Secret", clientSecret)
}.body()
SafeScraper.scrapeParallel(corpInfo,response.items)
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
}
println("📰 '${query}' 관련 뉴스 10개 학습 완료")
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
}
}
suspend fun fetchCorpInfo(corpCode: String): String {
val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
return try {
val response = client.get(url).body<CorpInfo>()
"기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
} catch (e: Exception) {
"기업 정보 로드 실패"
}
}
// suspend fun fetchCorpInfo(corpCode: String): String {
// val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
// val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
//
// return try {
// val response = client.get(url).body<CorpInfo>()
// "기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
// } catch (e: Exception) {
// "기업 정보 로드 실패"
// }
// }
suspend fun fetchFinancialGrowth(corpCode: String?): String {
if (corpCode != null) {
@@ -84,15 +75,12 @@ object NewsService {
return try {
val response = client.get(url).body<DartFinancialResponse>()
val accounts = response.list ?: return "재무 데이터 없음"
val revenue = accounts.find { it.account_nm == "매출액" }
val opProfit = accounts.find { it.account_nm == "영업이익" }
"""
[재무 분석 데이터]
- 매출액: (당기)${revenue?.thstrm_amount}, (전기)${revenue?.frmtrm_amount}
- 영업이익: (당기)${opProfit?.thstrm_amount}, (전기)${opProfit?.frmtrm_amount}
""".trimIndent()
var buffer : StringBuffer = StringBuffer()
buffer.append("[재무 분석 데이터]")
response.list.forEach { it
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
}
return buffer.toString()
} catch (e: Exception) {
"재무 API 연동 실패: ${e.message}"
}
+183 -68
View File
@@ -7,22 +7,23 @@ import dev.langchain4j.data.segment.TextSegment
import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
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
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import service.TechnicalAnalyzer
import service.TradingDecisionCallback
import service.UrlCacheManager
import java.nio.file.Paths
import java.time.Duration
object RagService {
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://127.0.0.1:8081/v1")
@@ -32,6 +33,7 @@ object RagService {
private val chatModel = OpenAiChatModel.builder()
.baseUrl("http://127.0.0.1:8080/v1")
.apiKey("unused")
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
.timeout(Duration.ofSeconds(60))
.build()
@@ -45,40 +47,104 @@ object RagService {
LuceneEmbeddingStore.builder()
.directory(directory)
.build()
}
fun active() {
println("[Cache] Active")
if (UrlCacheManager.isInitialized()) return
println("[Cache] initialize")
UrlCacheManager.initialize(embeddingStore, embeddingModel)
}
/**
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/
fun ingest(text: String, newsLink: String = "", pubDate: String = "") {
// 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성
val metadata = Metadata()
metadata.put("link", newsLink)
metadata.put("date", pubDate)
fun ingestWithChunking(
text: String,
newsLink: String = "",
pubDate: String = "",
stcokName: String,
corpCode: String,
corpName: String,
stockCode: String
) {
val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정
// TextSegment.from(text, metadata) 팩토리 메서드 활용
val segment = TextSegment.from(text, metadata)
val embedding = embeddingModel.embed(segment).content()
// 1. 문단 단위로 먼저 분리
val paragraphs = text.split(Regex("\n\n+"))
val chunks = mutableListOf<String>()
var currentChunk = StringBuilder()
// LuceneEmbeddingStore.add(Embedding, TextSegment) 호출
embeddingStore.add(embedding, segment)
println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...")
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}개의 청크로 인덱싱 완료")
}
suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) {
object JsonSanitizer {
fun formatJson(raw: String): String {
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
return raw.trim()
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
}
}
suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) {
// 1. 10분간의 데이터 가져오기 (API 호출)
coroutineScope {
var tradingDecision : TradingDecision = TradingDecision()
val corpCode = DartCodeManager.getCorpCode(stockCode)
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) }
tradingDecision.stockCode = stockCode
var corpInfo = DartCodeManager.getCorpCode(stockCode)
corpInfo?.stockName = stockName
corpInfo?.let { NewsService.fetchAndIngestNews(it) }
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
tradingDecision.financialData = financialDataDeferred.await()
result(tradingDecision.toString(),false)
result(tradingDecision,false)
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport()
result(tradingDecision.toString(),false)
result(tradingDecision,false)
val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘."
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
val questionEmbedding = embeddingModel.embed(question).content()
val searchResult = embeddingStore.search(
EmbeddingSearchRequest.builder()
@@ -87,12 +153,28 @@ object RagService {
.build()
)
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
result(tradingDecision.toString(),false)
decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: ""))
result(tradingDecision,false)
result(decideTrading(stockCode, tradingDecision),true)
}
}
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()
}
/**
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
@@ -144,57 +226,80 @@ object RagService {
suspend fun decideTrading(
stockName: String,
techSummary: String,
newsContext: String,
financialData: String
tempDecision: TradingDecision
): TradingDecision? {
val prompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
[데이터 요약]
- 종목: $stockName
$techSummary
- 기업/재무: $financialData
- 시장 심리: $newsContext
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
[데이터 요약]
- 종목: $stockName
- 분석: ${tempDecision.techSummary}
- 기업/재무: ${tempDecision.financialData}
- 시장 심리: ${tempDecision.newsContext}
[스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[응답 형식]
반드시 아래 JSON 형식으로만 답변하십시오:
{
"ultraShortScore": (숫자),
"shortTermScore": (숫자),
"midTermScore": (숫자),
"longTermScore": (숫자),
"decision": "BUY" | "SELL" | "HOLD",
"reason": "결정적 근거 한 줄",
"confidence": 0~100
}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
[응답 지침 - 엄격 준수]
1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
2. 오직 JSON 데이터만 출력하십시오.
3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
[응답 형식]
반드시 아래 JSON 형식으로만 답변하십시오:
{
"ultraShortScore": (숫자),
"shortTermScore": (숫자),
"midTermScore": (숫자),
"longTermScore": (숫자),
"decision": "BUY" | "SELL" | "HOLD",
"reason": "결정적 근거 한 줄",
"confidence": 0~100
}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent()
val response = chatModel.chat(UserMessage.from(prompt))
val jsonResponse = response.aiMessage().text()
val rawResponse = response.aiMessage().text()
val jsonResponse = JsonSanitizer.formatJson(rawResponse)
println("📥 [AI Raw JSON]:\n$jsonResponse")
// 2. 유연한 파서 설정 (소수점 및 예외 상황 대응)
val lenientJson = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
// JSON 파싱 (Kotlinx Serialization 활용)
return try {
println(jsonResponse)
val decision = Json.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = financialData
decision.newsContext = newsContext
decision.techSummary = techSummary
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = tempDecision.financialData
decision.newsContext = tempDecision.newsContext
decision.techSummary = tempDecision.techSummary
decision.stockCode = tempDecision.stockCode
decision
} catch (e: dev.langchain4j.exception.InternalServerException) {
// 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리
println("🚨 [AI Server Error] ${e.message}")
if (e.message?.contains("Context size") == true) {
println("⚠️ 데이터가 너무 많습니다. 요약 로직을 점검하세요.")
}
tempDecision
null
} catch (e: Exception) {
println("❌ [General Error] ${e.message}")
null
}
}
@@ -203,18 +308,28 @@ object RagService {
}
@Serializable
class TradingDecision {
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
val shortTermScore: Int = 0 // 단기 (봉/뉴스)
val midTermScore: Int = 0 // 기 (봉/재무)
val longTermScore: Int = 0
val ultraShortScore: Double = 0.0 // 단기 (봉/에너지)
val shortTermScore: Double = 0.0 // 기 (봉/뉴스)
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
val longTermScore: Double = 0.0
var stockCode: String = ""
var decision: String? = null
var reason: String? = null
var confidence: Int = 0
var confidence: Double = 0.0
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
fun profitPossible() =
listOf<Double>(ultraShortScore,
shortTermScore,
midTermScore,
longTermScore).average()
override fun toString(): String {
return """
수익실현 가능성 : ${profitPossible()}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
@@ -222,9 +337,9 @@ longTermScore :$longTermScore
decision: $decision
reason: $reason
confidence: $confidence
techSummary: $techSummary
newsContext: $newsContext
financialData: $financialData
기술 분석: $techSummary
뉴스: $newsContext
재무재표: $financialData
""".trimIndent()
}
}