ㅎㅎㅎ
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
object DartCodeManager {
|
||||
private val corpCodeMap = mutableMapOf<String, String>()
|
||||
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
|
||||
|
||||
/**
|
||||
* 앱 실행 시 호출하여 매핑 테이블 업데이트
|
||||
*/
|
||||
suspend fun updateCorpCodes(client: HttpClient) {
|
||||
println("📂 [DART] 법인코드 매핑 데이터 업데이트 시작...")
|
||||
|
||||
try {
|
||||
val url = "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$DART_API_KEY"
|
||||
val response: HttpResponse = client.get(url)
|
||||
val zipBytes = response.readBytes()
|
||||
|
||||
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis ->
|
||||
var entry = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == "CORPCODE.xml") {
|
||||
parseXml(zis.readAllBytes())
|
||||
break
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
println("✅ [DART] 매핑 완료: ${corpCodeMap.size}개의 상장사 로드됨")
|
||||
} catch (e: Exception) {
|
||||
println("❌ [DART] 법인코드 업데이트 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseXml(xmlBytes: ByteArray) {
|
||||
val factory = DocumentBuilderFactory.newInstance()
|
||||
val builder = factory.newDocumentBuilder()
|
||||
val doc = builder.parse(ByteArrayInputStream(xmlBytes))
|
||||
val nodeList = doc.getElementsByTagName("list")
|
||||
|
||||
for (i in 0 until nodeList.length) {
|
||||
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 ?: ""
|
||||
|
||||
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가
|
||||
if (stockCode.isNotEmpty()) {
|
||||
corpCodeMap[stockCode] = corpCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 6자리 종목코드로 8자리 법인코드 반환
|
||||
*/
|
||||
fun getCorpCode(stockCode: String): String? {
|
||||
return corpCodeMap[stockCode]
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class KisTradeService {
|
||||
object KisTradeService {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
@@ -225,11 +225,13 @@ class KisTradeService {
|
||||
val obj = element.jsonObject
|
||||
CandleData(
|
||||
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
|
||||
stck_clpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_prpr = obj["stck_prpr"]?.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",
|
||||
acml_vol = obj["acml_vol"]?.jsonPrimitive?.content ?: "0"
|
||||
cntg_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0",
|
||||
acml_tr_pbmn = obj["acml_tr_pbmn"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_cntg_hour = obj["stck_cntg_hour"]?.jsonPrimitive?.content ?: "0",
|
||||
)
|
||||
}?.reversed() ?: emptyList()
|
||||
|
||||
@@ -448,7 +450,7 @@ class KisTradeService {
|
||||
val path = if (isDomestic)
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
val now = LocalTime.now()
|
||||
val now = LocalTime.now().minusMinutes(30)
|
||||
val searchTime = if (now.isAfter(LocalTime.of(15, 30))) {
|
||||
"153000"
|
||||
} else {
|
||||
@@ -478,11 +480,13 @@ class KisTradeService {
|
||||
val obj = element.jsonObject
|
||||
CandleData(
|
||||
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
|
||||
stck_clpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
|
||||
stck_prpr = obj["stck_prpr"]?.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",
|
||||
acml_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0" // 필수 필드 누락 방지
|
||||
cntg_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0",
|
||||
acml_tr_pbmn = obj["acml_tr_pbmn"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_cntg_hour = obj["stck_cntg_hour"]?.jsonPrimitive?.content ?: "0",
|
||||
)
|
||||
}?.reversed() ?: emptyList()
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ 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
|
||||
|
||||
object NewsService {
|
||||
@@ -50,7 +52,8 @@ object NewsService {
|
||||
// RAG 서비스에 학습(Ingest) 시키기
|
||||
RagService.ingest(
|
||||
text = fullText,
|
||||
meta = "{\"link\": \"${item.originallink}\", \"date\": \"${item.pubDate}\"}"
|
||||
newsLink = item.originallink,
|
||||
pubDate = item.pubDate
|
||||
)
|
||||
}
|
||||
println("📰 '${query}' 관련 뉴스 10개 학습 완료")
|
||||
@@ -58,4 +61,45 @@ object NewsService {
|
||||
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 fetchFinancialGrowth(corpCode: String?): String {
|
||||
if (corpCode != null) {
|
||||
val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
|
||||
// 단일회사 주요계정 API (재무상태표, 손익계산서 주요 항목)
|
||||
val url = "https://opendart.fss.or.kr/api/fnlttSinglAcnt.json?crtfc_key=$apiKey&corp_code=$corpCode&bsns_year=2024&reprt_code=11011"
|
||||
|
||||
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()
|
||||
} catch (e: Exception) {
|
||||
"재무 API 연동 실패: ${e.message}"
|
||||
}
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
// src/main/kotlin/network/RagService.kt
|
||||
|
||||
import VectorStoreTable.metadata
|
||||
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
|
||||
import dev.langchain4j.data.document.Metadata
|
||||
import dev.langchain4j.data.message.UserMessage
|
||||
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 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.SqlExpressionBuilder.plus
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import service.TechnicalAnalyzer
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
|
||||
object RagService {
|
||||
@@ -22,60 +35,168 @@ object RagService {
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.build()
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
|
||||
*/
|
||||
fun ingest(text: String, meta: String = "") {
|
||||
val embeddingVector: DoubleArray = embeddingModel.embed(text).content().vector().map { it.toDouble() }.toDoubleArray()
|
||||
transaction {
|
||||
VectorStoreTable.insert {
|
||||
it[content] = text
|
||||
it[metadata] = meta
|
||||
// [수정] 문자열 변환 없이 객체 그대로 전달
|
||||
it[embedding] = embeddingVector
|
||||
}
|
||||
}
|
||||
println("💾 H2 벡터 저장 완료: ${text.take(15)}...")
|
||||
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()
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)) }
|
||||
|
||||
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 ?: ""))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
|
||||
*/
|
||||
fun askWithContext(question: String): String {
|
||||
val queryVector = embeddingModel.embed(question).content().vector()
|
||||
// H2 ARRAY 포맷에 맞춰 (v1, v2, ...) 형태로 변환
|
||||
val vectorStr = queryVector.joinToString(",", "(", ")")
|
||||
|
||||
val context = transaction {
|
||||
// 코사인 유사도 기준 상위 5개 뉴스 추출
|
||||
val query = """
|
||||
SELECT CONTENT FROM VECTOR_STORE
|
||||
ORDER BY VECTOR_COSINE_SIMILARITY(EMBEDDING, CAST('$vectorStr' AS FLOAT8 ARRAY)) DESC
|
||||
LIMIT 5
|
||||
""".trimIndent()
|
||||
|
||||
val results = mutableListOf<String>()
|
||||
exec(query) { rs ->
|
||||
while (rs.next()) {
|
||||
results.add(rs.getString("CONTENT"))
|
||||
}
|
||||
}
|
||||
results.joinToString("\n\n")
|
||||
}
|
||||
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|>
|
||||
당신은 실시간 뉴스 분석에 능통한 20년 경력의 주식 전문가입니다.
|
||||
제공된 [참고 자료]를 바탕으로 사용자의 질문에 전문적이고 단호하게 답하세요.<|eot_id|>
|
||||
<|start_header_id|>user<|end_header_id|>
|
||||
[참고 자료]
|
||||
$context
|
||||
당신은 뉴스(심리), 재무(본질), 차트(추세)를 통합 분석하는 'AI 수석 애널리스트'입니다.
|
||||
제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요.
|
||||
|
||||
[질문]
|
||||
$question
|
||||
[데이터 세트]
|
||||
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()
|
||||
|
||||
return chatModel.generate(finalPrompt)
|
||||
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 = """
|
||||
당신은 단기 데이트레이딩 전문가입니다. 아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
||||
|
||||
[종목]: $stockName
|
||||
$techSummary
|
||||
[관련 뉴스]: $newsContext
|
||||
[재무 기초]: $financialData
|
||||
|
||||
반드시 아래 JSON 형식으로만 답변하세요:
|
||||
{
|
||||
"decision": "BUY" | "SELL" | "HOLD",
|
||||
"reason": "결정적 근거 한 줄",
|
||||
"confidence": 0~100
|
||||
}
|
||||
""".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 {
|
||||
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 """
|
||||
decision: $decision
|
||||
reason: $reason
|
||||
confidence: $confidence
|
||||
techSummary: $techSummary
|
||||
newsContext: $newsContext
|
||||
financialData: $financialData
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user