This commit is contained in:
lunaticbum 2026-01-23 17:05:09 +09:00
parent ac50737ea8
commit d89d793efa
16 changed files with 665 additions and 256 deletions

View File

@ -33,7 +33,8 @@ dependencies {
// Database (Exposed & SQLite) // Database (Exposed & SQLite)
// H2 Database (네이티브 라이브러리 없는 순수 자바 DB) // H2 Database (네이티브 라이브러리 없는 순수 자바 DB)
implementation("com.h2database:h2:2.2.224") implementation("com.h2database:h2:2.2.224")
// Source: https://mvnrepository.com/artifact/com.microsoft.playwright/playwright
implementation("com.microsoft.playwright:playwright:1.57.0")
implementation("io.ktor:ktor-client-websockets:${ktorVersion}") implementation("io.ktor:ktor-client-websockets:${ktorVersion}")
// SQL 프레임워크 (Exposed) // SQL 프레임워크 (Exposed)

View File

@ -21,7 +21,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
import model.AppConfig import model.AppConfig
import model.KisSession import model.KisSession
import network.DartCodeManager import network.DartCodeManager
import network.LlamaServerManager import service.LlamaServerManager
import network.NewsService import network.NewsService
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import ui.DashboardScreen import ui.DashboardScreen

View File

@ -25,7 +25,20 @@ data class CandleData(
val stck_prpr: String, // 현제가 val stck_prpr: String, // 현제가
val cntg_vol: String, val cntg_vol: String,
val acml_tr_pbmn: String, val acml_tr_pbmn: String,
) ) {
override fun toString(): String {
return """
stck_cntg_hour : $stck_cntg_hour
stck_bsop_date : $stck_bsop_date
stck_oprc : $stck_oprc
stck_hgpr : $stck_hgpr
stck_lwpr : $stck_lwpr
stck_prpr : $stck_prpr
cntg_vol : $cntg_vol
acml_tr_pbmn : $acml_tr_pbmn
""".trimIndent()
}
}
@Serializable @Serializable
data class OverseasCandleData( data class OverseasCandleData(
val o_sign: String = "", // 대비 기호 val o_sign: String = "", // 대비 기호

View File

@ -8,8 +8,15 @@ import java.io.File
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
data class CorpInfo(
var cCode : String = "",
var cName : String = "",
var stockCode : String = "",
var stockName : String = "",
)
object DartCodeManager { object DartCodeManager {
private val corpCodeMap = mutableMapOf<String, String>() private val corpCodeMap = mutableMapOf<String, CorpInfo>()
private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력 private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력
private fun saveXmlDebugFile(xmlBytes: ByteArray) { private fun saveXmlDebugFile(xmlBytes: ByteArray) {
@ -63,10 +70,11 @@ object DartCodeManager {
val element = nodeList.item(i) as org.w3c.dom.Element val element = nodeList.item(i) as org.w3c.dom.Element
val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: "" val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: ""
val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: "" 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)가 있는 상장사만 매핑에 추가 // 종목코드(stock_code)가 있는 상장사만 매핑에 추가
if (stockCode.isNotEmpty()) { if (stockCode.isNotEmpty()) {
corpCodeMap[stockCode] = corpCode corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode)
} }
} }
} }
@ -74,7 +82,7 @@ object DartCodeManager {
/** /**
* 6자리 종목코드로 8자리 법인코드 반환 * 6자리 종목코드로 8자리 법인코드 반환
*/ */
fun getCorpCode(stockCode: String): String? { fun getCorpCode(stockCode: String): CorpInfo? {
// 1. 직접 매칭 시도 // 1. 직접 매칭 시도
corpCodeMap[stockCode]?.let { return it } corpCodeMap[stockCode]?.let { return it }

View File

@ -220,12 +220,12 @@ object KisTradeService {
val body = response.body<JsonObject>() val body = response.body<JsonObject>()
val output2 = body["output2"]?.jsonArray val output2 = body["output2"]?.jsonArray
println("output2 ${output2}")
val candles = output2?.map { element -> val candles = output2?.map { element ->
val obj = element.jsonObject val obj = element.jsonObject
CandleData( CandleData(
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", 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_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0", stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",

View File

@ -15,65 +15,56 @@ import io.ktor.client.request.parameter
import io.ktor.http.ContentType.Application.Json import io.ktor.http.ContentType.Application.Json
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.CorpInfo
import model.DartFinancialResponse import model.DartFinancialResponse
import model.NaverNewsResponse import model.NaverNewsResponse
import service.DynamicNewsScraper
import service.SafeScraper
import service.UrlCacheManager
object NewsService { object NewsService {
private val client = HttpClient<CIOEngineConfig>(CIO) { private val client = HttpClient<CIOEngineConfig>(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) 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 clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요
val clientSecret = "DODCxb1M4Z" val clientSecret = "DODCxb1M4Z"
var qlist = listOf<String>("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}")
try { qlist.forEach { query ->
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { try {
parameter("query", query) val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
parameter("display", 10) // 최근 10개 뉴스 parameter("query", query)
parameter("sort", "sim") // 유사도 순 (또는 date 발간순) parameter("display", 3) // 최근 10개 뉴스
header("X-Naver-Client-Id", clientId) parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
header("X-Naver-Client-Secret", clientSecret) header("X-Naver-Client-Id", clientId)
}.body() header("X-Naver-Client-Secret", clientSecret)
}.body()
response.items.forEach { item -> SafeScraper.scrapeParallel(corpInfo,response.items)
// HTML 태그 제거 및 텍스트 정제 } catch (e: Exception) {
val cleanTitle = item.title.replace(Regex("<[^>]*>"), "") println("❌ 뉴스 가져오기 실패: ${e.message}")
val cleanDesc = item.description.replace(Regex("<[^>]*>"), "")
val fullText = "[$cleanTitle] $cleanDesc"
println(fullText)
// RAG 서비스에 학습(Ingest) 시키기
RagService.ingest(
text = fullText,
newsLink = item.originallink,
pubDate = item.pubDate
)
} }
println("📰 '${query}' 관련 뉴스 10개 학습 완료")
} catch (e: Exception) {
println("❌ 뉴스 가져오기 실패: ${e.message}")
} }
} }
suspend fun fetchCorpInfo(corpCode: String): String { // suspend fun fetchCorpInfo(corpCode: String): String {
val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc" // val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc"
val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode" // val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode"
//
return try { // return try {
val response = client.get(url).body<CorpInfo>() // val response = client.get(url).body<CorpInfo>()
"기업명: ${response.corp_name}, 주요사업: ${response.main_business}" // "기업명: ${response.corp_name}, 주요사업: ${response.main_business}"
} catch (e: Exception) { // } catch (e: Exception) {
"기업 정보 로드 실패" // "기업 정보 로드 실패"
} // }
} // }
suspend fun fetchFinancialGrowth(corpCode: String?): String { suspend fun fetchFinancialGrowth(corpCode: String?): String {
if (corpCode != null) { if (corpCode != null) {
@ -84,15 +75,12 @@ object NewsService {
return try { return try {
val response = client.get(url).body<DartFinancialResponse>() val response = client.get(url).body<DartFinancialResponse>()
val accounts = response.list ?: return "재무 데이터 없음" val accounts = response.list ?: return "재무 데이터 없음"
var buffer : StringBuffer = StringBuffer()
val revenue = accounts.find { it.account_nm == "매출액" } buffer.append("[재무 분석 데이터]")
val opProfit = accounts.find { it.account_nm == "영업이익" } response.list.forEach { it
buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n")
""" }
[재무 분석 데이터] return buffer.toString()
- 매출액: (당기)${revenue?.thstrm_amount}, (전기)${revenue?.frmtrm_amount}
- 영업이익: (당기)${opProfit?.thstrm_amount}, (전기)${opProfit?.frmtrm_amount}
""".trimIndent()
} catch (e: Exception) { } catch (e: Exception) {
"재무 API 연동 실패: ${e.message}" "재무 API 연동 실패: ${e.message}"
} }

View File

@ -7,22 +7,23 @@ import dev.langchain4j.data.segment.TextSegment
import dev.langchain4j.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel
import dev.langchain4j.store.embedding.EmbeddingSearchRequest import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.CandleData import model.CandleData
import network.DartCodeManager import network.DartCodeManager
import network.KisTradeService
import network.NewsService import network.NewsService
import org.apache.lucene.store.MMapDirectory import org.apache.lucene.store.MMapDirectory
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import service.TechnicalAnalyzer import service.TechnicalAnalyzer
import service.TradingDecisionCallback
import service.UrlCacheManager
import java.nio.file.Paths import java.nio.file.Paths
import java.time.Duration import java.time.Duration
object RagService { object RagService {
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정 // 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel.builder() private val embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://127.0.0.1:8081/v1") .baseUrl("http://127.0.0.1:8081/v1")
@ -32,6 +33,7 @@ object RagService {
private val chatModel = OpenAiChatModel.builder() private val chatModel = OpenAiChatModel.builder()
.baseUrl("http://127.0.0.1:8080/v1") .baseUrl("http://127.0.0.1:8080/v1")
.apiKey("unused") .apiKey("unused")
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
.timeout(Duration.ofSeconds(60)) .timeout(Duration.ofSeconds(60))
.build() .build()
@ -45,40 +47,104 @@ object RagService {
LuceneEmbeddingStore.builder() LuceneEmbeddingStore.builder()
.directory(directory) .directory(directory)
.build() .build()
} }
fun active() {
println("[Cache] Active")
if (UrlCacheManager.isInitialized()) return
println("[Cache] initialize")
UrlCacheManager.initialize(embeddingStore, embeddingModel)
}
/** /**
* 텍스트를 임베딩하여 H2 DB에 저장합니다. * 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/ */
fun ingest(text: String, newsLink: String = "", pubDate: String = "") { fun ingestWithChunking(
// 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성 text: String,
val metadata = Metadata() newsLink: String = "",
metadata.put("link", newsLink) pubDate: String = "",
metadata.put("date", pubDate) stcokName: String,
corpCode: String,
corpName: String,
stockCode: String
) {
val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정
// TextSegment.from(text, metadata) 팩토리 메서드 활용 // 1. 문단 단위로 먼저 분리
val segment = TextSegment.from(text, metadata) val paragraphs = text.split(Regex("\n\n+"))
val embedding = embeddingModel.embed(segment).content() val chunks = mutableListOf<String>()
var currentChunk = StringBuilder()
// LuceneEmbeddingStore.add(Embedding, TextSegment) 호출 for (para in paragraphs) {
embeddingStore.add(embedding, segment) // 현재 청크에 문단을 더했을 때 제한을 넘으면 지금까지의 내용을 확정
println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...") 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 호출) // 1. 10분간의 데이터 가져오기 (API 호출)
coroutineScope { coroutineScope {
var tradingDecision : TradingDecision = TradingDecision() var tradingDecision : TradingDecision = TradingDecision()
val corpCode = DartCodeManager.getCorpCode(stockCode) tradingDecision.stockCode = stockCode
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) } var corpInfo = DartCodeManager.getCorpCode(stockCode)
corpInfo?.stockName = stockName
corpInfo?.let { NewsService.fetchAndIngestNews(it) }
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
tradingDecision.financialData = financialDataDeferred.await() tradingDecision.financialData = financialDataDeferred.await()
result(tradingDecision.toString(),false) result(tradingDecision,false)
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() 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 questionEmbedding = embeddingModel.embed(question).content()
val searchResult = embeddingStore.search( val searchResult = embeddingStore.search(
EmbeddingSearchRequest.builder() EmbeddingSearchRequest.builder()
@ -87,12 +153,28 @@ object RagService {
.build() .build()
) )
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
result(tradingDecision.toString(),false) result(tradingDecision,false)
decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: "")) 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 답변을 생성합니다. * 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
@ -144,57 +226,80 @@ object RagService {
suspend fun decideTrading( suspend fun decideTrading(
stockName: String, stockName: String,
techSummary: String, tempDecision: TradingDecision
newsContext: String,
financialData: String
): TradingDecision? { ): TradingDecision? {
val prompt = """ val prompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|> <|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다. 당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오. 제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
아래 데이터를 분석하여 '매수', '매도', '관망' 하나를 결정하세요. 아래 데이터를 분석하여 '매수', '매도', '관망' 하나를 결정하세요.
[데이터 요약] [데이터 요약]
- 종목: $stockName - 종목: $stockName
$techSummary - 분석: ${tempDecision.techSummary}
- 기업/재무: $financialData - 기업/재무: ${tempDecision.financialData}
- 시장 심리: $newsContext - 시장 심리: ${tempDecision.newsContext}
[스코어 산출 가이드 (0-100)] [스코어 산출 가이드 (0-100)]
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80 이상. 1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80 이상.
2. 단기: 일봉 이평선 정배열 3 변동률 양수일 70 이상. 2. 단기: 일봉 이평선 정배열 3 변동률 양수일 70 이상.
3. 중기: 주봉 추세와 재무 성장성(매출/영익) 동반 상승 75 이상. 3. 중기: 주봉 추세와 재무 성장성(매출/영익) 동반 상승 75 이상.
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단. 4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
[응답 형식] [응답 지침 - 엄격 준수]
반드시 아래 JSON 형식으로만 답변하십시오: 1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
{ 2. 오직 JSON 데이터만 출력하십시오.
"ultraShortScore": (숫자), 3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
"shortTermScore": (숫자), 4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
"midTermScore": (숫자), [응답 형식]
"longTermScore": (숫자), 반드시 아래 JSON 형식으로만 답변하십시오:
"decision": "BUY" | "SELL" | "HOLD", {
"reason": "결정적 근거 한 줄", "ultraShortScore": (숫자),
"confidence": 0~100 "shortTermScore": (숫자),
} "midTermScore": (숫자),
<|eot_id|> "longTermScore": (숫자),
<|start_header_id|>user<|end_header_id|> "decision": "BUY" | "SELL" | "HOLD",
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오. "reason": "결정적 근거 한 줄",
<|eot_id|><|start_header_id|>assistant<|end_header_id|> "confidence": 0~100
}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
""".trimIndent() """.trimIndent()
val response = chatModel.chat(UserMessage.from(prompt)) 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 활용) // JSON 파싱 (Kotlinx Serialization 활용)
return try { return try {
println(jsonResponse) println(jsonResponse)
val decision = Json.decodeFromString<TradingDecision>(jsonResponse) val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
decision.financialData = financialData decision.financialData = tempDecision.financialData
decision.newsContext = newsContext decision.newsContext = tempDecision.newsContext
decision.techSummary = techSummary decision.techSummary = tempDecision.techSummary
decision.stockCode = tempDecision.stockCode
decision 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) { } catch (e: Exception) {
println("❌ [General Error] ${e.message}")
null null
} }
} }
@ -203,18 +308,28 @@ object RagService {
} }
@Serializable @Serializable
class TradingDecision { class TradingDecision {
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
val shortTermScore: Int = 0 // 단기 (일봉/뉴스) val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
val midTermScore: Int = 0 // 중기 (주봉/재무) val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
val longTermScore: Int = 0 val midTermScore: Double = 0.0 // 중기 (주봉/재무)
val longTermScore: Double = 0.0
var stockCode: String = ""
var decision: String? = null var decision: String? = null
var reason: String? = null var reason: String? = null
var confidence: Int = 0 var confidence: Double = 0.0
var techSummary : String? = null var techSummary : String? = null
var newsContext : String? = null var newsContext : String? = null
var financialData : String? = null var financialData : String? = null
fun profitPossible() =
listOf<Double>(ultraShortScore,
shortTermScore,
midTermScore,
longTermScore).average()
override fun toString(): String { override fun toString(): String {
return """ return """
수익실현 가능성 : ${profitPossible()}
ultraShortScore :$ultraShortScore ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore shortTermScore :$shortTermScore
midTermScore :$midTermScore midTermScore :$midTermScore
@ -222,9 +337,9 @@ longTermScore :$longTermScore
decision: $decision decision: $decision
reason: $reason reason: $reason
confidence: $confidence confidence: $confidence
techSummary: $techSummary 기술 분석: $techSummary
newsContext: $newsContext 뉴스: $newsContext
financialData: $financialData 재무재표: $financialData
""".trimIndent() """.trimIndent()
} }
} }

View File

@ -3,13 +3,8 @@ package service
import TradingDecision import TradingDecision
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.CandleData import model.CandleData
import network.KisTradeService
import network.NewsService
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
@ -18,31 +13,34 @@ import kotlin.collections.List
import kotlin.math.* import kotlin.math.*
// service/AutoTradingManager.kt // service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
object AutoTradingManager { object AutoTradingManager {
private val scope = CoroutineScope(Dispatchers.Default) private val scope = CoroutineScope(Dispatchers.Default)
val targetStocks = mutableListOf<String>() val targetStocks = mutableListOf<Pair<String, String>>()
fun addStock(stockCode : String, result :(String, Boolean)->Unit) { fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) {
targetStocks.add(stockCode) targetStocks.add(Pair(stockName, stockCode))
startTradingLoop(result) startTradingLoop(stockName,stockCode,result)
} }
fun startTradingLoop(result :(String, Boolean)->Unit) { fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) {
scope.launch { scope.launch {
println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}") println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}")
targetStocks.forEach { stockCode -> // targetStocks.forEach { stockCode ->
launch { // 종목별 병렬 분석 (M3 Pro 파워 활용) launch { // 종목별 병렬 분석 (M3 Pro 파워 활용)
RagService.processStock(stockCode,result) {code ,decision -> RagService.processStock(stockName, stockCode,result)
when (decision?.decision) { // {decision,b ->
"BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수") //// when (decision?.decision) {
"SELL" -> executeOrder(stockCode, "매도") //// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
else -> println("[$stockCode] 관망 유지: ${decision?.reason}") //// "SELL" -> executeOrder(stockCode, "매도")
} //// else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
result(decision.toString(),true) //// }
} // result(decision,b)
// }
} }
} // }
delay(10 * 60 * 1000) // 10분 대기 // targetStocks.re
// delay(10 * 60 * 1000) // 10분 대기
} }
} }
@ -112,7 +110,7 @@ object TechnicalAnalyzer {
// [3] 이평선 및 가격 위치 // [3] 이평선 및 가격 위치
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average() val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
val currentPrice = min30.last().stck_prpr.toDouble() val currentPrice = min30.last().stck_prpr.toDouble()
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList()) val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish())
// [4] 거래량 강도 // [4] 거래량 강도
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average() val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average() val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
@ -121,30 +119,29 @@ object TechnicalAnalyzer {
val stochK = calculateStochastic(min30) val stochK = calculateStochastic(min30)
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() } val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
return """ return """
[초단기 기술적 스켈핑 분석] - /단타 종합 스코어: ${signal.compositeScore} / 100
- 종합 스코어: ${signal.compositeScore} / 100 - /단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
- 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"} - /단타 성공 확률 예측: ${signal.successProbPct}%
- 성공 확률 예측: ${signal.successProbPct}% - /단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
- 위험 등급: ${signal.riskLevel} (ATR 변동성 기반) - /단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}
- RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)} - /단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}), 익절가(${signal.suggestedTpPrice.toInt()})
- 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}), 익절가(${signal.suggestedTpPrice.toInt()})
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"} - 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동 - 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
- 30 대비: ${ "%.2f".format(change30) }% 변동 - 30 대비: ${ "%.2f".format(change30) }% 변동
- 10 대비: ${ "%.2f".format(change10) }% 변동 - 10 대비: ${ "%.2f".format(change10) }% 변동
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"} - 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"}) - OBV (누적 거래량 에너지): ${ "%.0f".format(obv) }
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20) - MFI (자금 유입 지수): ${ "%.1f".format(mfi) }
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치) - A/D (누적 분산 라인): ${ "%.0f".format(adLine) }
- 거래량 강도: 최근 5 평균이 30 평균의 ${ "%.1f".format(volStrength) } 수준 - 거래량 강도: 최근 5 평균이 30 평균의 ${ "%.1f".format(volStrength) } 수준
- ATR (평균 변동폭): ${"%.0f".format(atr)} (최근 캔들 하나가 평균적으로 움직이는 크기) - ATR (평균 변동폭): ${"%.0f".format(atr)}
- 30 최대 진폭: ${"%.0f".format(priceRange30)} (최고가-최저가 차이) - 30 최대 진폭: ${"%.0f".format(priceRange30)}
- 스토캐스틱(%K): ${"%.1f".format(stochK)} (100 가까울수록 최근 파동의 고점, 0 가까울수록 저점) - 스토캐스틱(%K): ${"%.1f".format(stochK)}
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)} 수준으로 전개 - 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)} 수준
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }} - 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }} - 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) } - RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
""".trimIndent() """.trimIndent()
} }
/** /**
@ -194,6 +191,25 @@ object TechnicalAnalyzer {
return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100 return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100
} }
fun isDailyBullish(): Boolean {
if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
val currentPrice = daily.last().stck_prpr.toDouble()
// 1. MA20 (한 달 생명선) 계산
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
// 2. MA5 (단기 가속도) 계산
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
val isMa5Rising = ma5 > prevMa5
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
return currentPrice > ma20 && isMa5Rising
}
fun calculateOBV(candles: List<CandleData>): Double { fun calculateOBV(candles: List<CandleData>): Double {
var obv = 0.0 var obv = 0.0
for (i in 1 until candles.size) { for (i in 1 until candles.size) {
@ -298,7 +314,7 @@ class ScalpingAnalyzer {
return Triple(upper, sma, lower) return Triple(upper, sma, lower)
} }
fun analyze(candles: List<Candle>): ScalpingSignalModel { fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요") if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
val closes = candles.map { it.close } val closes = candles.map { it.close }
@ -323,16 +339,34 @@ class ScalpingAnalyzer {
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last()) (currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
} else 0.5 } else 0.5
// 신호 조건
val maBull = currentClose > sma10Now && sma10Now > sma20Now
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
val isBreakout = currentClose > nearHigh
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
val bodySize = abs(current.close - current.open)
val lowerShadow = minOf(current.close, current.open) - current.low
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
// 신호 조건 고도화
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
val rsiBull = rsiNow > RSI_THRESHOLD val rsiBull = rsiNow > RSI_THRESHOLD
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD val volSurge = volRatioNow > VOL_SURGE_THRESHOLD
val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS
val buySignal = maBull && rsiBull && volSurge && bbGood val maBull = currentClose > sma10Now && sma10Now > sma20Now
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
// val buySignal = maBull && rsiBull && volSurge && bbGood
// 종합 스코어 (가중: MA 30%, RSI 20%, Vol 30%, BB 20%)
val score = (if (maBull) 30 else 0) + (if (rsiBull) 20 else 0) + val score = (if (maBull) 25 else 0) +
(minOf((volRatioNow - 1.0) * 30, 30.0)).toInt() + (if (bbGood) 20 else 0) (if (rsiBull) 15 else 0) +
(if (isBreakout) 20 else 0) + // 돌파 에너지 가중치
(minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() +
(if (bbGood) 10 else 0) +
(if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수
// 위험도 (ATR proxy) // 위험도 (ATR proxy)
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 } val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
@ -351,6 +385,8 @@ class ScalpingAnalyzer {
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100) val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT) val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
return ScalpingSignalModel( return ScalpingSignalModel(
currentPrice = currentClose, currentPrice = currentClose,
buySignal = buySignal, buySignal = buySignal,

View File

@ -0,0 +1,160 @@
package service
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.BrowserType
import com.microsoft.playwright.Page
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import model.NewsItem
import network.CorpInfo
import kotlin.random.Random
object DynamicNewsScraper {
private val playwright by lazy { Playwright.create() }
private val browser by lazy {
playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true))
}
fun extractSmartContentWithLineFilter(page: Page): String {
val script = """
() => {
// 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV'];
document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove());
const MIN_LINE_LENGTH = 10;
const MIN_TOTAL_LENGTH = 100;
const CONSECUTIVE_THRESHOLD = 2;
// 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리)
const getRefinedText = (el) => {
// 실제 텍스트만 추출하여 라인별로 분리
const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
let resultLines = [];
let tempBuffer = [];
let consecutiveShort = 0;
lines.forEach(line => {
if (line.length <= MIN_LINE_LENGTH) {
consecutiveShort++;
tempBuffer.push(line);
} else {
// 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
resultLines = resultLines.concat(tempBuffer);
}
resultLines.push(line);
tempBuffer = [];
consecutiveShort = 0;
}
});
// 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비)
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
resultLines = resultLines.concat(tempBuffer);
}
return resultLines.join('\n');
};
// 3. 후보 블록 탐색 및 텍스트 밀도 기반 분석
const candidates = Array.from(document.querySelectorAll('div, section, article, p, main, td'))
.map(el => ({
el: el,
refinedText: getRefinedText(el)
}))
.filter(item => {
if (item.refinedText.length < MIN_TOTAL_LENGTH) return false;
// 링크 밀도 체크: 기사 본문은 보통 링크보다 텍스트 비중이 훨씬 높음
const linkLength = Array.from(item.el.querySelectorAll('a'))
.reduce((acc, a) => acc + (a.innerText || "").length, 0);
return (linkLength / item.refinedText.length) < 0.3;
});
// 4. 가장 최적의(가장 깊은 계층의) 본문 컨테이너 선정
const best = candidates.find(parent =>
!candidates.some(child =>
parent.el !== child.el &&
parent.el.contains(child.el) &&
child.refinedText.length > parent.refinedText.length * 0.8
)
);
return best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || "");
}
""".trimIndent()
return page.evaluate(script) as String
}
suspend fun fetchFullContent(url: String): String {
val context = browser.newContext()
val page = context.newPage()
delay(Random.nextInt(1000).toLong())
return try {
// 1. 페이지 이동 및 네트워크 유휴 상태까지 대기
blockUnnecessaryResources(page)
page.navigate(url)
// println(url)
page.waitForLoadState()
var finded = cleanText(extractSmartContentWithLineFilter(page))
println("finded : $finded")
finded
} catch (e: Exception) {
println("❌ [Playwright] 스크래핑 실패: ${e.message}")
""
} finally {
page.close()
context.close()
}
}
private fun blockUnnecessaryResources(page: Page) {
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route ->
route.abort()
}
}
private fun cleanText(text: String): String {
return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거
.replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거
.trim()
}
}
object SafeScraper {
// 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다)
private val semaphore = Semaphore(5)
suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List<NewsItem>) = coroutineScope {
var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}"
urls.map { item ->
async {
if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) {
semaphore.withPermit {
RagService.ingestWithChunking(
text = DynamicNewsScraper.fetchFullContent(item.originallink),
newsLink = item.originallink,
pubDate = item.pubDate,
stockCode = corpInfo.stockCode,
corpName = corpInfo.cName,
corpCode = corpInfo.cCode,
stcokName = corpInfo.stockName
)
}
println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링")
} else {
println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
}
}
}.awaitAll()
println("$query 관련 뉴스 ${urls.size}개 학습 완료")
}
}

View File

@ -1,9 +1,12 @@
package network package service
import java.io.File import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
object LlamaServerManager { object LlamaServerManager {
@ -24,7 +27,7 @@ object LlamaServerManager {
binPath, binPath,
"-m", modelPath, "-m", modelPath,
"--port", port.toString(), "--port", port.toString(),
"-c", if (port == 8081) "512" else "4096", // 임베딩용은 컨텍스트가 짧아도 충분합니다. "-c", if (port == 8081) "512" else "8192", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
"-ngl", nGpuLayers.toString(), "-ngl", nGpuLayers.toString(),
"-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장 "-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장
"--embedding" // 임베딩 기능을 활성화합니다. "--embedding" // 임베딩 기능을 활성화합니다.
@ -45,15 +48,20 @@ object LlamaServerManager {
var line: String? var line: String?
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
// 로그 출력 (디버깅용) // 로그 출력 (디버깅용)
println("[Server $port] $line") // println("[Server $port] $line")
if (line?.contains("server is listening") == true) { if (line?.contains("server is listening") == true) {
println("🚀 AI 서버 준비 완료 (Port: $port)") println("🚀 AI 서버 준비 완료 (Port: $port)")
if (processes.size > 1) {
println("[Cache] ${processes.size}")
RagService.active()
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}") println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}")
processes.remove(port) processes.remove(port)
} }
} }
} }

View File

@ -0,0 +1,55 @@
package service
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.model.embedding.EmbeddingModel
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
import java.util.concurrent.ConcurrentHashMap
object UrlCacheManager {
// 1. Thread-safe한 메모리 캐시 (M3 Pro의 멀티코어 환경 대응)
private val processedUrls = ConcurrentHashMap.newKeySet<String>()
/**
* Lucene 저장소에서 모든 link 메타데이터를 읽어와 캐시를 초기화합니다.
*/
fun initialize(embeddingStore: LuceneEmbeddingStore, embeddingModel: EmbeddingModel) {
try {
// 1. 더미 텍스트로 기준 벡터 생성 (또는 0으로 채워진 리스트 생성)
// 모델에게 아무 단어나 던져서 기준이 될 벡터 하나를 임시로 만듭니다.
val dummyEmbedding = embeddingModel.embed("initial_load").content()
// 2. 검색 요청에 더미 벡터 포함
val searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(dummyEmbedding) // [필수] 기준 벡터 주입으로 에러 해결
.maxResults(2000)
.build()
val result = embeddingStore.search(searchRequest)
result.matches().forEach { match ->
val url = match.embedded().metadata().getString("link") as? String
if (url != null) {
processedUrls.add(url)
}
}
println("✅ [Cache] Lucene으로부터 ${processedUrls.size}개의 URL 로드 완료")
} catch (e: Exception) {
println("⚠️ [Cache] 초기화 실패: ${e.message}")
}
}
fun isInitialized(): Boolean {
return processedUrls.isNotEmpty()
}
/**
* 중복 여부 확인 (O(1) 속도)
*/
fun isAlreadyProcessed(url: String): Boolean = processedUrls.contains(url)
/**
* 캐시 업데이트
*/
fun addToCache(url: String) {
processedUrls.add(url)
}
}

View File

@ -26,9 +26,10 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.KisSession import model.KisSession
import service.AutoTradingManager import service.AutoTradingManager
import service.TradingDecisionCallback
@Composable @Composable
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) { fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") } var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var code by remember(stockCode) { var code by remember(stockCode) {
aiOpinion = "" aiOpinion = ""
@ -66,18 +67,10 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra
scope.launch { scope.launch {
isAnalyzing = true isAnalyzing = true
try { try {
AutoTradingManager.addStock(stockCode) { msg,success -> AutoTradingManager.addStock(stockName,stockCode) { decision,success ->
aiOpinion = msg aiOpinion = decision.toString()
isAnalyzing = !success isAnalyzing = !success
} }
// 실시간 데이터 수집부터 분석까지 한 번에 실행
// StockAnalysisManager.analyzeStockWithMultiData(
// stockCode = stockCode,
// stockName = stockName,
// result = {
// aiOpinion = it
// }
// )
} catch (e: Exception) { } catch (e: Exception) {
aiOpinion = "분석 중 오류 발생: ${e.message}" aiOpinion = "분석 중 오류 발생: ${e.message}"
println(aiOpinion) println(aiOpinion)

View File

@ -152,10 +152,6 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
Text(
"매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
fontSize = 11.sp, color = Color.Gray
)
} }
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
} }
@ -174,6 +170,10 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text(
"매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
fontSize = 11.sp, color = Color.Gray
)
} }
} }
} }

View File

@ -2,6 +2,7 @@
package ui package ui
import AutoTradeItem import AutoTradeItem
import TradingDecision
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
@ -16,7 +17,6 @@ import model.KisSession
import model.StockBasicInfo import model.StockBasicInfo
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import util.MarketUtil
@Composable @Composable
fun DashboardScreen() { fun DashboardScreen() {
@ -30,6 +30,8 @@ fun DashboardScreen() {
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시 var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시 var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
// 중앙 관리용 상태들 // 중앙 관리용 상태들
var refreshTrigger by remember { mutableStateOf(0) } var refreshTrigger by remember { mutableStateOf(0) }
@ -141,7 +143,8 @@ fun DashboardScreen() {
scope.launch { scope.launch {
syncAndExecute(orderNo) // 매칭 시도 syncAndExecute(orderNo) // 매칭 시도
} }
} },
completeTradingDecision
) )
} else { } else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -156,7 +159,12 @@ fun DashboardScreen() {
stockCode = selectedStockCode, stockCode = selectedStockCode,
stockName = selectedStockName, stockName = selectedStockName,
currentPrice = wsManager.currentPrice.value, currentPrice = wsManager.currentPrice.value,
trades = wsManager.tradeLogs trades = wsManager.tradeLogs,
tradingDecisionCallback = { decision,bool ->
if (bool && decision != null && KisSession.config.isSimulation) {
completeTradingDecision = decision
}
}
) )
} }
VerticalDivider() VerticalDivider()
@ -196,8 +204,12 @@ fun DashboardScreen() {
} }
} }
} }
} }
@Composable @Composable
fun VerticalDivider() { fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))

View File

@ -2,7 +2,7 @@
package ui package ui
import AutoTradeItem import AutoTradeItem
import androidx.compose.foundation.background import TradingDecision
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -21,7 +21,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil import util.MarketUtil
/** /**
@ -40,7 +39,8 @@ fun IntegratedOrderSection(
holdingQuantity: String, holdingQuantity: String,
tradeService: KisTradeService, tradeService: KisTradeService,
onOrderSaved: (String) -> Unit, onOrderSaved: (String) -> Unit,
onOrderResult: (String, Boolean) -> Unit onOrderResult: (String, Boolean) -> Unit,
completeTradingDecision: TradingDecision?
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -60,6 +60,7 @@ fun IntegratedOrderSection(
} }
// UI 입력 상태 // UI 입력 상태
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
var orderQty by remember(holdingQuantity) { var orderQty by remember(holdingQuantity) {
@ -80,6 +81,48 @@ fun IntegratedOrderSection(
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0) val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
fun excuteTrade(willEnableAutoSell: Boolean,orderQty: String) {
scope.launch {
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) {
val pRate = profitRate.toDoubleOrNull() ?: 0.0
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = pRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
isDomestic = isDomestic
))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo)
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
}
}
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
}
}
LaunchedEffect(completeTradingDecision) {
if (completeTradingDecision != null &&
completeTradingDecision.stockCode.equals(stockCode)) {
when (completeTradingDecision?.decision) {
"BUY" -> if (completeTradingDecision.confidence > 70) excuteTrade(true, "1")
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
else -> println("[$stockCode] 관망 유지: ${completeTradingDecision?.reason}")
}
}
}
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
@ -171,36 +214,7 @@ fun IntegratedOrderSection(
// 매수 버튼 // 매수 버튼
Button( Button(
onClick = { onClick = {
scope.launch { excuteTrade(willEnableAutoSell,orderQty)
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) {
val pRate = profitRate.toDoubleOrNull() ?: 0.0
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = pRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
isDomestic = isDomestic
))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo)
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
}
}
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
}
}, },
modifier = Modifier.weight(1f).padding(end = 4.dp), modifier = Modifier.weight(1f).padding(end = 4.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
@ -224,6 +238,8 @@ fun IntegratedOrderSection(
) { Text("매도", color = Color.White) } ) { Text("매도", color = Color.White) }
} }
} }
} }
@Composable @Composable

View File

@ -2,15 +2,10 @@ package ui
import androidx.compose.foundation.background import TradingDecision
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다. // 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -18,20 +13,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.CandleData import model.CandleData
import model.RankingStock import network.DartCodeManager
import model.StockHolding
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import network.NewsService
import service.TechnicalAnalyzer import service.TechnicalAnalyzer
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -41,11 +31,12 @@ import kotlin.collections.isNotEmpty
fun StockDetailSection( fun StockDetailSection(
stockCode: String, stockCode: String,
stockName: String, stockName: String,
holdingQuantity : String, holdingQuantity: String,
isDomestic: Boolean, isDomestic: Boolean,
tradeService: KisTradeService, tradeService: KisTradeService,
wsManager: KisWebSocketManager, wsManager: KisWebSocketManager,
onOrderSaved: (String) -> Unit onOrderSaved: (String) -> Unit,
completeTradingDecision: TradingDecision?
) { ) {
var openPrice by remember { mutableStateOf("0") } var openPrice by remember { mutableStateOf("0") }
@ -65,11 +56,7 @@ fun StockDetailSection(
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0" if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
} }
fun calculateAvg(data: List<CandleData>): String {
if (data.isEmpty()) return "0"
val avg = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average()
return String.format("%,d", avg.toLong())
}
// 이전 종목 코드를 기억하기 위한 상태 // 이전 종목 코드를 기억하기 위한 상태
var previousCode by remember { mutableStateOf("") } var previousCode by remember { mutableStateOf("") }
@ -88,6 +75,7 @@ fun StockDetailSection(
wsManager.subscribeStock(stockCode) wsManager.subscribeStock(stockCode)
previousCode = stockCode previousCode = stockCode
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
coroutineScope { coroutineScope {
@ -101,18 +89,33 @@ fun StockDetailSection(
.onFailure { error -> .onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList() chartData = emptyList()
}} }
}
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
daySummary = it.takeLast(7) } daySummary = it.takeLast(7)
TechnicalAnalyzer.daily = daySummary TechnicalAnalyzer.daily = it
println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
} // 최근 7일 } // 최근 7일
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
TechnicalAnalyzer.weekly = weekSummary} // 최근 4주 weekSummary = it.takeLast(4)
TechnicalAnalyzer.weekly = it
println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
} // 최근 4주
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
monthSummary = it.takeLast(6) // 최근 6개월 monthSummary = it.takeLast(6) // 최근 6개월
yearSummary = it.takeLast(36) // 최근 3년 yearSummary = it.takeLast(36) // 최근 3년
TechnicalAnalyzer.monthly = yearSummary TechnicalAnalyzer.monthly = it
}} println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
}
launch {
DartCodeManager.getCorpCode(stockCode)?.let {
it.stockName = stockName
NewsService.fetchAndIngestNews(it)
}
}
} }
isLoading = false isLoading = false
} }
@ -230,7 +233,8 @@ fun StockDetailSection(
onOrderResult = { msg, success -> onOrderResult = { msg, success ->
resultMessage = msg resultMessage = msg
isSuccess = success isSuccess = success
} },
completeTradingDecision
) )
} }
} }