2026-03-17 10:50:13 +09:00
package network// src / main / kotlin / network / RagService . kt
2026-01-21 18:30:03 +09:00
2026-03-27 17:54:21 +09:00
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
2026-03-26 14:42:39 +09:00
import TradingLogStore
2026-01-22 16:21:18 +09:00
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
import dev.langchain4j.data.document.Metadata
2026-01-21 18:30:03 +09:00
import dev.langchain4j.data.segment.TextSegment
2026-03-17 10:50:13 +09:00
import dev.langchain4j.exception.InternalServerException
2026-01-21 18:30:03 +09:00
import dev.langchain4j.model.openai.OpenAiChatModel
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
2026-03-17 10:50:13 +09:00
import dev.langchain4j.service.AiServices
import dev.langchain4j.service.SystemMessage
2026-01-22 16:21:18 +09:00
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
2026-01-23 17:05:09 +09:00
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
2026-03-17 10:50:13 +09:00
import kotlinx.coroutines.Dispatchers
2026-01-22 16:21:18 +09:00
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
2026-03-18 16:01:42 +09:00
import kotlinx.coroutines.delay
2026-03-17 10:50:13 +09:00
import kotlinx.coroutines.withContext
2026-01-22 16:21:18 +09:00
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
2026-03-17 10:50:13 +09:00
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
2026-04-01 15:03:11 +09:00
import model.ConfigIndex
import model.KisSession
2026-04-02 14:05:14 +09:00
import model.RankingStock
2026-03-17 10:50:13 +09:00
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
2026-01-22 16:21:18 +09:00
import org.apache.lucene.store.MMapDirectory
2026-03-17 10:50:13 +09:00
import org.slf4j.MDC.put
2026-03-27 17:45:51 +09:00
import service.AutoTradingManager
2026-02-10 15:08:52 +09:00
import service.FinancialAnalyzer
import service.InvestmentScores
2026-01-22 16:21:18 +09:00
import service.TechnicalAnalyzer
2026-01-23 17:05:09 +09:00
import service.TradingDecisionCallback
import service.UrlCacheManager
2026-01-22 16:21:18 +09:00
import java.nio.file.Paths
2026-01-21 18:30:03 +09:00
import java.time.Duration
2026-04-06 15:07:14 +09:00
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
2026-03-17 10:50:13 +09:00
import java.util.concurrent.TimeUnit
2026-01-21 18:30:03 +09:00
2026-04-06 15:07:14 +09:00
//interface TradingAnalyst {
// @SystemMessage("""
// You are a Senior Stock Analyst.
// Analyze the data and provide a decision in JSON format.
// You must respond ONLY with a valid JSON object.
// """)
// fun analyzeStock(@dev.langchain4j.service.UserMessage prompt: String): TradingDecision
//}
2026-03-16 17:07:25 +09:00
2026-01-21 18:30:03 +09:00
object RagService {
2026-01-23 17:05:09 +09:00
2026-01-21 18:30:03 +09:00
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel . builder ( )
2026-03-27 17:54:21 +09:00
. baseUrl ( " http://127.0.0.1: ${EMBEDDING_PORT} /v1 " )
2026-01-21 18:30:03 +09:00
. apiKey ( " unused " )
. build ( )
private val chatModel = OpenAiChatModel . builder ( )
2026-03-27 17:54:21 +09:00
. baseUrl ( " http://127.0.0.1: ${LLM_PORT} /v1 " )
2026-01-21 18:30:03 +09:00
. apiKey ( " unused " )
2026-01-23 17:05:09 +09:00
. temperature ( 0.0 ) // [중요] 0.0으로 설정하여 결정론적 응답 유도
2026-01-21 18:30:03 +09:00
. timeout ( Duration . ofSeconds ( 60 ) )
2026-03-17 10:50:13 +09:00
// .frequencyPenalty(1.1)
. maxTokens ( 400 ) // 👈 루프 방지를 위해 반드시 짧게 제한!
2026-03-16 17:07:25 +09:00
// 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요
. responseFormat ( " json_object " )
2026-01-21 18:30:03 +09:00
. build ( )
2026-04-06 15:07:14 +09:00
// private val analyst = AiServices.builder(TradingAnalyst::class.java)
// .chatModel(chatModel)
// .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-23 17:05:09 +09:00
}
fun active ( ) {
2026-02-03 18:07:18 +09:00
// println("[Cache] Active")
2026-01-23 17:05:09 +09:00
if ( UrlCacheManager . isInitialized ( ) ) return
2026-02-03 18:07:18 +09:00
// println("[Cache] initialize")
2026-01-23 17:05:09 +09:00
UrlCacheManager . initialize ( embeddingStore , embeddingModel )
2026-01-22 16:21:18 +09:00
}
2026-01-23 17:05:09 +09:00
2026-03-16 17:07:25 +09:00
2026-01-21 18:30:03 +09:00
/ * *
* 텍스트를 임베딩하여 H2 DB에 저장합니다 .
* /
2026-01-23 17:05:09 +09:00
fun ingestWithChunking (
text : String ,
newsLink : String = " " ,
pubDate : String = " " ,
stcokName : String ,
corpCode : String ,
corpName : String ,
stockCode : String
) {
val MAX _CHUNK _SIZE = 500 // 안전하게 500자 내외로 설정
// 1. 문단 단위로 먼저 분리
val paragraphs = text . split ( Regex ( " \n \n + " ) )
val chunks = mutableListOf < String > ( )
var currentChunk = StringBuilder ( )
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 )
}
}
2026-02-12 13:11:07 +09:00
// println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료")
2026-01-22 16:21:18 +09:00
}
2026-01-23 17:05:09 +09:00
object JsonSanitizer {
fun formatJson ( raw : String ) : String {
2026-03-17 10:50:13 +09:00
// 실제 응답 로그 출력 (디버깅용)
println ( " 📥 [AI Raw Response]: \n $raw " )
2026-01-23 17:05:09 +09:00
val regex = Regex ( """ \{.*\} """ , RegexOption . DOT _MATCHES _ALL )
2026-03-17 10:50:13 +09:00
val match = regex . find ( raw ) ?. value
if ( match == null ) {
println ( " ⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다. " )
return " {} " // 빈 객체라도 반환하여 EOF 방지
}
return match . trim ( )
2026-01-23 17:05:09 +09:00
. removePrefix ( " ```json " )
. removePrefix ( " ``` " )
. removeSuffix ( " ``` " )
. trim ( )
}
}
2026-04-06 15:07:14 +09:00
private fun isRecentNews ( dateStr : String ? , maxDays : Long = 3 ) : Boolean {
if ( dateStr . isNullOrBlank ( ) ) return false
return try {
// 네이버 뉴스 OpenAPI 기본 포맷: "Mon, 06 Apr 2026 12:00:00 +0900"
val formatter = DateTimeFormatter . ofPattern ( " EEE, dd MMM yyyy HH:mm:ss Z " , Locale . ENGLISH )
val pubDate = ZonedDateTime . parse ( dateStr , formatter )
val now = ZonedDateTime . now ( )
// 뉴스가 미래로 표기된 경우도 대비하여 절대값 처리
Math . abs ( ChronoUnit . DAYS . between ( pubDate , now ) ) <= maxDays
} catch ( e : Exception ) {
// 다른 날짜 포맷(예: "yyyy.MM.dd")으로 들어오는 경우를 위한 Fallback
try {
val fallbackFormatter = DateTimeFormatter . ofPattern ( " yyyy.MM.dd HH:mm " , Locale . ENGLISH )
val pubDate = ZonedDateTime . parse ( " $dateStr 00:00 +0900 " , fallbackFormatter )
Math . abs ( ChronoUnit . DAYS . between ( pubDate , ZonedDateTime . now ( ) ) ) <= maxDays
} catch ( e2 : Exception ) {
false // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도
}
}
}
2026-03-27 13:38:05 +09:00
suspend fun processStock ( currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer , stockName : String , stockCode : String , result : TradingDecisionCallback ) {
val totalStartTime = System . currentTimeMillis ( ) // 전체 시작 시간
2026-01-22 16:21:18 +09:00
coroutineScope {
2026-02-03 18:07:18 +09:00
try {
2026-03-27 13:38:05 +09:00
var tradingDecision = TradingDecision ( )
2026-02-03 18:07:18 +09:00
tradingDecision . stockCode = stockCode
2026-02-19 15:47:31 +09:00
tradingDecision . analyzer = technicalAnalyzer
2026-02-13 13:49:40 +09:00
tradingDecision . currentPrice = currentPrice
2026-03-27 13:38:05 +09:00
2026-02-03 18:07:18 +09:00
var corpInfo = DartCodeManager . getCorpCode ( stockCode )
corpInfo ?. stockName = stockName
tradingDecision . stockName = stockName
tradingDecision . corpName = corpInfo ?. cName ?: " "
2026-03-27 13:38:05 +09:00
// 1. 재무 데이터 가져오기 시간 측정
val financialStartTime = System . currentTimeMillis ( )
val financialDataDeferred = async { NewsService . fetchFinancialGrowth ( corpInfo ?. cCode ?: " " ) }
2026-02-03 18:07:18 +09:00
tradingDecision . financialData = financialDataDeferred . await ( )
2026-02-10 15:08:52 +09:00
val financialStmt = FinancialMapper . mapRawTextToStatement ( tradingDecision . financialData ?: " " )
2026-03-27 13:38:05 +09:00
val financialDuration = System . currentTimeMillis ( ) - financialStartTime
println ( " ⏱️ [ $stockName ] 재무 분석 소요: ${financialDuration} ms " )
2026-02-10 15:08:52 +09:00
if ( FinancialAnalyzer . isSafetyBeltMet ( financialStmt ) ) {
2026-03-27 13:38:05 +09:00
// 3. 기술적 지표 계산 시간 측정
val techStartTime = System . currentTimeMillis ( )
2026-02-10 15:08:52 +09:00
val financialScore = FinancialAnalyzer . calculateScore ( financialStmt )
val scores = technicalAnalyzer . calculateScores ( financialScore )
2026-03-27 13:38:05 +09:00
val techDuration = System . currentTimeMillis ( ) - techStartTime
println ( " ⏱️ [ $stockName ] 기술적 지표 계산 소요: ${techDuration} ms " )
2026-04-01 15:03:11 +09:00
val guideLine = KisSession . config . getValues ( ConfigIndex . MIN _PURCHASE _SCORE _INDEX )
2026-04-06 15:07:14 +09:00
if ( scores . avg ( ) > ( guideLine . times ( 0.50 ) ) ) {
2026-04-01 14:35:56 +09:00
// 2. 뉴스 스크래핑 및 학습 시간 측정
2026-04-06 15:07:14 +09:00
val ragStartTime = System . currentTimeMillis ( )
val question = " ${corpInfo?.cName} $stockName [ $stockCode ]의 향후 실적 전망과 관련된 핵심 뉴스 "
val questionEmbedding = embeddingModel . embed ( question ) . content ( )
// --- 💡 [수정됨] 2. 해당 주식의 최신 뉴스 존재 여부 확인 (최대 10개) ---
val preSearchResult = embeddingStore . search (
EmbeddingSearchRequest . builder ( )
. queryEmbedding ( questionEmbedding )
. filter ( MetadataFilterBuilder . metadataKey ( " stockCode " ) . isEqualTo ( stockCode ) ) // 해당 종목만 필터링
. maxResults ( 10 )
. minScore ( 0.3 ) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
. build ( )
)
// 검색된 청크들 중 최근 3일 이내의 날짜를 가진 데이터가 하나라도 있는지 확인
val hasRecentData = preSearchResult . matches ( ) . any { match ->
val pubDate = match . embedded ( ) . metadata ( ) . getString ( " date " )
isRecentNews ( pubDate , maxDays = 1 )
}
// --- 💡 [수정됨] 3. 최신 데이터가 없을 때만 브라우저 스크래핑(Playwright) 실행 ---
2026-04-01 14:35:56 +09:00
val newsIngestStartTime = System . currentTimeMillis ( )
2026-04-06 15:07:14 +09:00
if ( ! hasRecentData ) {
println ( " 🌐 [ $stockName ] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다. " )
corpInfo ?. let {
try {
NewsService . fetchAndIngestNews ( it )
} catch ( e : Exception ) {
println ( " ❌ [ $stockName ] 뉴스 스크래핑 실패: ${e.message} " )
}
}
} else {
println ( " ✅ [ $stockName ] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다. " )
2026-04-01 14:35:56 +09:00
}
val newsIngestDuration = System . currentTimeMillis ( ) - newsIngestStartTime
2026-04-06 15:07:14 +09:00
println ( " ⏱️ [ $stockName ] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration} ms " )
2026-04-01 14:35:56 +09:00
2026-03-23 10:54:54 +09:00
result ( tradingDecision , false )
tradingDecision . techSummary = technicalAnalyzer . generateComprehensiveReport ( )
result ( tradingDecision , false )
2026-04-06 15:07:14 +09:00
// --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 ---
// (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다)
val finalSearchResult = embeddingStore . search (
2026-03-23 10:54:54 +09:00
EmbeddingSearchRequest . builder ( )
. queryEmbedding ( questionEmbedding )
2026-04-06 15:07:14 +09:00
. filter ( MetadataFilterBuilder . metadataKey ( " stockCode " ) . isEqualTo ( stockCode ) ) // 교차 오염 방지를 위해 필터 필수
2026-03-23 10:54:54 +09:00
. maxResults ( 3 )
2026-04-06 15:07:14 +09:00
. minScore ( 0.3 ) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
2026-03-23 10:54:54 +09:00
. build ( )
)
2026-04-06 15:07:14 +09:00
println ( " 🔎 [ $stockName ] RAG 검색된 문서 개수: ${finalSearchResult.matches().size} 개 " )
finalSearchResult . matches ( ) . forEach { match ->
println ( " 📊 [RAG Score: ${match.score()} ] 본문: ${match.embedded().text().replace("\n", " ").take(50)} ... " )
}
tradingDecision . newsContext = finalSearchResult . matches ( ) . joinToString ( " \n " ) { it . embedded ( ) . text ( ) }
2026-03-27 13:38:05 +09:00
val ragDuration = System . currentTimeMillis ( ) - ragStartTime
println ( " ⏱️ [ $stockName ] RAG 뉴스 검색 소요: ${ragDuration} ms " )
2026-03-23 10:54:54 +09:00
result ( tradingDecision , false )
2026-03-27 13:38:05 +09:00
TradingLogStore . addAnalyzer ( stockName , stockCode , " ${FinancialAnalyzer.toString(financialStmt)} ${scores.toString()} " , true )
// 5. AI 최종 결정(LLM 호출) 시간 측정
val aiDecisionStartTime = System . currentTimeMillis ( )
val finalDecision = decideTrading ( stockCode , scores , financialStmt , tradingDecision )
val aiDecisionDuration = System . currentTimeMillis ( ) - aiDecisionStartTime
println ( " ⏱️ [ $stockName ] AI 최종 판단 소요: ${aiDecisionDuration} ms " )
val totalDuration = System . currentTimeMillis ( ) - totalStartTime
println ( " ✅ [ $stockName ] 전체 분석 완료 총 소요: ${totalDuration} ms " )
// 상세 로그 남기기
2026-03-30 17:00:39 +09:00
TradingLogStore . addAnalyzer ( stockName , stockCode , " 분석시간 상세: 재무( ${financialDuration} ms), 뉴스( ${newsIngestDuration} ms), RAG( ${ragDuration} ms), AI( ${aiDecisionDuration} ms), 전체 분석 완료( ${totalDuration} ms) " , true )
2026-03-27 13:38:05 +09:00
result ( finalDecision , true )
2026-03-23 10:54:54 +09:00
} else {
2026-04-02 11:16:53 +09:00
println ( " ✋ [ $stockName ] 기술 점수 미달로 분석 중단 ${scores.toString()} " )
2026-04-01 15:03:11 +09:00
TradingLogStore . addAnalyzer ( stockName , stockCode , " 기술 점수 미달로 분석 중단 " )
2026-04-02 14:05:14 +09:00
if ( FinancialAnalyzer . isBuyConsiderationMet ( financialStmt ) ) {
TradingLogStore . addLog ( tradingDecision , " WATCH " , " 우량주로 판단되나 거래량 혹은 최근 거래 점수 미달로 재분석 대상에 추가 " )
AutoTradingManager . addToReanalysis ( RankingStock ( mksc _shrn _iscd = stockCode , hts _kor _isnm = stockName ) )
}
2026-03-26 13:48:26 +09:00
tradingDecision . confidence = 1.0
2026-03-23 10:54:54 +09:00
result ( tradingDecision , false )
}
2026-02-10 15:08:52 +09:00
} else {
2026-04-02 11:19:42 +09:00
println ( " 🚨 [ $stockName ] ${FinancialAnalyzer.toString(financialStmt)} 재무 안전벨트 미달 " )
2026-04-02 14:05:14 +09:00
TradingLogStore . addAnalyzer ( stockName , stockCode , " 재무 안전벨트 미달로 분석 중단 ${FinancialAnalyzer.toString(financialStmt)} " )
2026-03-26 13:48:26 +09:00
tradingDecision . confidence = 1.0
2026-02-10 15:08:52 +09:00
result ( tradingDecision , false )
}
2026-03-27 13:38:05 +09:00
} catch ( e : Exception ) {
2026-02-03 18:07:18 +09:00
e . printStackTrace ( )
}
2026-01-21 18:30:03 +09:00
}
}
2026-01-23 17:05:09 +09:00
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 ( )
}
2026-01-22 16:21:18 +09:00
2026-03-30 13:41:26 +09:00
private fun LLM _API _URL ( ) = " http://127.0.0.1: $LLM _PORT/v1/chat/completions "
2026-03-17 10:50:13 +09:00
private suspend fun callLlamaWithSchema ( prompt : String ) : String {
val jsonMediaType = " application/json; charset=utf-8 " . toMediaType ( )
// 문자열 치환 대신 안전한 JSON 객체 빌더 사용
val requestBodyJson = buildJsonObject {
put ( " model " , " local-model " )
2026-03-18 16:01:42 +09:00
put ( " temperature " , 0.1 ) // 💡 루프 탈출을 위해 더 과감하게 설정
put ( " top_p " , 0.85 ) // 💡 추가
put ( " top_k " , 40 ) // 💡 추가 (서버에서 지원할 경우)
// put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화
// put("presence_penalty", 0.5)
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
put ( " max_tokens " , 400 )
2026-03-17 10:50:13 +09:00
putJsonArray ( " messages " ) {
addJsonObject {
put ( " role " , " system " )
put ( " content " , " You are a helpful AI financial analyst. You must output responses ONLY in valid JSON format. " )
}
addJsonObject {
put ( " role " , " user " )
put ( " content " , prompt )
}
}
2026-01-22 16:21:18 +09:00
2026-03-17 10:50:13 +09:00
// 💡 복잡한 json_schema를 지우고, 단순히 JSON 형식으로만 내보내라고 지시합니다.
putJsonObject ( " response_format " ) {
put ( " type " , " json_object " )
}
} . toString ( )
println ( " requestBodyJson =>> $requestBodyJson " )
val request = Request . Builder ( )
2026-03-30 13:41:26 +09:00
. url ( LLM _API _URL ( ) )
2026-03-17 10:50:13 +09:00
. post ( requestBodyJson . toRequestBody ( jsonMediaType ) )
. build ( )
return kotlinx . coroutines . Dispatchers . IO . let {
kotlinx . coroutines . withContext ( it ) {
httpClient . newCall ( request ) . execute ( ) . use { response ->
if ( ! response . isSuccessful ) throw Exception ( " LLM API Error: ${response.code} ${response.message} " )
val responseBody = response . body ?. string ( ) ?: " {} "
val json = Json . parseToJsonElement ( responseBody ) . jsonObject
json [ " choices " ] ?. jsonArray ?. get ( 0 ) ?. jsonObject ?. get ( " message " ) ?. jsonObject ?. get ( " content " ) ?. jsonPrimitive ?. content ?: " {} "
}
}
}
}
private val httpClient = OkHttpClient . Builder ( )
. connectTimeout ( 60 , TimeUnit . SECONDS )
. readTimeout ( 120 , TimeUnit . SECONDS )
. build ( )
2026-03-18 16:01:42 +09:00
2026-01-22 16:21:18 +09:00
suspend fun decideTrading (
stockName : String ,
2026-03-18 16:01:42 +09:00
scores : InvestmentScores ,
financialStmt : FinancialStatement ,
2026-01-23 17:05:09 +09:00
tempDecision : TradingDecision
2026-01-22 16:21:18 +09:00
) : TradingDecision ? {
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
var retryCount = 0
val maxRetries = 2
while ( retryCount <= maxRetries ) {
// 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단
val validNews = tempDecision . newsContext ?. takeIf { it . trim ( ) . length >= 100 } ?. take ( 400 )
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
// 2. 뉴스 유무에 따른 동적 데이터 섹션 구성
val newsDataSection = if ( validNews != null ) {
2026-04-02 11:16:53 +09:00
" 4. News Context: $validNews "
2026-03-18 16:01:42 +09:00
} else {
2026-04-02 11:16:53 +09:00
" 4. News Context: No significant news available. Rely on financials. "
2026-03-18 16:01:42 +09:00
}
val prompt = """
2026-03-16 17:07:25 +09:00
# Task : Senior AI Investment Analyst
2026-03-18 16:01:42 +09:00
Your goal is to provide a final trading decision based on STRICT data analysis .
# Data ( SOURCE OF TRUTH )
2026-03-16 17:07:25 +09:00
1. System Scores : Scalping ( $ { scores . ultraShort } ) , Short ( $ { scores . shortTerm } ) , Mid ( $ { scores . midTerm } ) , Long ( $ { scores . longTerm } )
2026-03-18 16:01:42 +09:00
2026-03-16 17:07:25 +09:00
2. Financials : Operating Profit $ { if ( financialStmt . isOperatingProfitPositive ) " PROFIT " else " LOSS " } ( Growth : $ { " %.2f " . format ( financialStmt . operatingProfitGrowth ) } % ) , ROE : $ { " %.2f " . format ( financialStmt . roe ) } % , Debt : $ { " %.2f " . format ( financialStmt . debtRatio ) } %
2026-03-18 16:01:42 +09:00
2026-04-02 11:16:53 +09:00
3. Technical Analysis Summary : $ { tempDecision . techSummary ?: " No technical summary available. " }
2026-03-17 10:50:13 +09:00
$ newsDataSection
2026-03-16 17:07:25 +09:00
2026-04-02 11:16:53 +09:00
2026-03-18 16:01:42 +09:00
# Step - by - Step Analysis Logic
1. Financial Review : First , evaluate the ' Financials ' section for long - term stability .
2. News Verification : Second , check ' News Context ' ( if available ) for immediate market sentiment or specific issues .
3. Synthesis : Finalize the ' decision ' ( BUY , SELL , HOLD ) by combining the Financials and News analysis .
4. Confidence : Assign a confidence score ( 0 - 100 ) based on how clearly the data points to the decision .
2026-04-01 13:05:12 +09:00
# Confidence Scoring Guide ( CRITICAL )
Assign the ' confidence ' score based on these rules :
- 80 - 100 : When Financials are strong AND News Context clearly supports the trend .
- 50 - 79 : When Financials are stable but News is neutral or missing .
- 10 - 49 : When Financials and News contradict each other .
- 1 - 9 : Reserved ONLY for extreme data corruption .
- NEVER output 0 unless the data is completely unreadable . Even a weak guess should be at least 10.
2026-03-18 16:01:42 +09:00
# Strict Constraints
- SCORE INTEGRITY : You MUST copy the ' System Scores ' into the output JSON exactly as provided . NO TRANSFORMATION .
- REASON LENGTH : The " reason " field MUST be written in KOREAN and MUST be between 10 to 50 characters .
- JSON ONLY : Output ONLY a valid JSON object . No markdown , no pre - text , no post - text .
# Output JSON Structure ( STRICT NAMES )
2026-03-16 17:07:25 +09:00
{
2026-03-18 16:01:42 +09:00
" ultraShortScore " : $ { scores . ultraShort } ,
" shortTermScore " : $ { scores . shortTerm } ,
" midTermScore " : $ { scores . midTerm } ,
" longTermScore " : $ { scores . longTerm } ,
2026-03-17 10:50:13 +09:00
" decision " : " HOLD " ,
2026-03-18 16:01:42 +09:00
" reason " : " 10자 이상 50자 이내의 한국어 분석 결과 " ,
" confidence " : 0
2026-03-16 17:07:25 +09:00
}
2026-03-18 16:01:42 +09:00
2026-03-16 17:07:25 +09:00
""" .trimIndent()
2026-03-18 16:01:42 +09:00
try {
val rawResponse = callLlamaWithSchema ( prompt )
// 환각 및 루프 검사
println ( " rawResponse $rawResponse " )
2026-01-22 16:21:18 +09:00
2026-01-23 17:05:09 +09:00
2026-03-18 16:01:42 +09:00
val sanitized = rawResponse . trim ( ) . removeSurrounding ( " ```json " , " ``` " ) . trim ( )
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
val decision = Json { ignoreUnknownKeys = true ; isLenient = true } . decodeFromString < TradingDecision > ( sanitized )
2026-01-23 17:05:09 +09:00
2026-03-18 16:01:42 +09:00
// 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영)
val reasonLen = decision . reason ?. length ?: 0
val isReasonValid = reasonLen in 5. . 60 // 약간의 마진 허용
2026-03-17 10:50:13 +09:00
2026-03-18 16:01:42 +09:00
// 점수가 보존되었는지 확인 (Scalping 점수 대조)
val isScorePreserved = decision . ultraShortScore == scores . ultraShort . toDouble ( )
if ( isReasonValid && isScorePreserved ) {
return decision . apply {
this . stockCode = tempDecision . stockCode
this . stockName = tempDecision . stockName
this . corpName = tempDecision . corpName
this . financialData = tempDecision . financialData
this . newsContext = tempDecision . newsContext
}
} else {
println ( " ⚠️ [검증 실패] 사유길이( $reasonLen ) 또는 점수보존( $isScorePreserved ) 실패. 재시도 합니다. " )
retryCount ++
}
2026-01-22 16:21:18 +09:00
2026-03-18 16:01:42 +09:00
} catch ( e : Exception ) {
println ( " ❌ [파싱 오류] ${e.message} - 재시도 시도 중... ( ${retryCount + 1} ) " )
retryCount ++
delay ( 500 )
2026-01-23 17:05:09 +09:00
}
2026-03-18 16:01:42 +09:00
}
// 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환
println ( " 🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다. " )
return TradingDecision ( ) . apply {
this . stockName = stockName
this . decision = " HOLD "
this . reason = " AI 분석 지연으로 인한 자동 관망 처리 "
2026-01-22 16:21:18 +09:00
}
}
2026-03-18 16:01:42 +09:00
2026-01-22 16:21:18 +09:00
}
2026-02-20 15:21:38 +09:00
2026-01-22 16:21:18 +09:00
@Serializable
class TradingDecision {
2026-02-03 18:07:18 +09:00
var corpName : String = " "
var stockName : String = " "
2026-01-23 17:05:09 +09:00
val ultraShortScore : Double = 0.0 // 초단기 (분봉/에너지)
val shortTermScore : Double = 0.0 // 단기 (일봉/뉴스)
val midTermScore : Double = 0.0 // 중기 (주봉/재무)
val longTermScore : Double = 0.0
2026-02-03 18:07:18 +09:00
// [추가] 화면 전환용 종목명
var currentPrice : Double = 0.0
2026-01-23 17:05:09 +09:00
var stockCode : String = " "
2026-01-22 16:21:18 +09:00
var decision : String ? = null
var reason : String ? = null
2026-01-23 17:05:09 +09:00
var confidence : Double = 0.0
2026-01-22 16:21:18 +09:00
var techSummary : String ? = null
var newsContext : String ? = null
var financialData : String ? = null
2026-02-19 15:47:31 +09:00
var analyzer : TechnicalAnalyzer ? = null
2026-02-04 14:52:09 +09:00
fun shortPossible ( ) =
listOf < Double > ( ultraShortScore ,
shortTermScore ) . average ( )
2026-01-23 17:05:09 +09:00
fun profitPossible ( ) =
listOf < Double > ( ultraShortScore ,
2026-02-03 18:07:18 +09:00
shortTermScore ,
midTermScore ,
longTermScore ) . average ( )
fun safePossible ( ) =
listOf < Double > (
midTermScore ,
longTermScore ) . average ( )
2026-01-23 17:05:09 +09:00
2026-01-22 16:21:18 +09:00
override fun toString ( ) : String {
return """
2026-02-03 18:07:18 +09:00
$ corpName ( $ stockName )
2026-01-23 17:05:09 +09:00
수익실현 가능성 : $ { profitPossible ( ) }
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
2026-01-23 17:05:09 +09:00
기술 분석 : $ techSummary
뉴스 : $ newsContext
재무재표 : $ financialData
2026-01-22 16:21:18 +09:00
""" .trimIndent()
2026-01-21 18:30:03 +09:00
}
2026-04-01 14:35:56 +09:00
}
object FinancialMapper {
/ * *
* 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환
* /
fun mapRawTextToStatement ( rawText : String ) : FinancialStatement {
if ( rawText . isBlank ( ) ) {
return FinancialStatement ( )
}
// println(rawText)
val currentValues = extractYearlyValues ( rawText , " 당기 " )
val previousValues = extractYearlyValues ( rawText , " 전기 " )
// 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100
val opCurrent = currentValues [ " 영업이익 " ] ?: 0.0
val opPrevious = previousValues [ " 영업이익 " ] ?: 0.0
val opGrowth = if ( opPrevious != 0.0 ) ( ( opCurrent - opPrevious ) / Math . abs ( opPrevious ) ) * 100 else 0.0
// 2. 당기순이익 증가율
val niCurrent = currentValues [ " 당기순이익(손실) " ] ?: 0.0
val niPrevious = previousValues [ " 당기순이익(손실) " ] ?: 0.0
val niGrowth = if ( niPrevious != 0.0 ) ( ( niCurrent - niPrevious ) / Math . abs ( niPrevious ) ) * 100 else 0.0
// 3. ROE: 당기순이익 / 당기 자본총계 * 100
val equityCurrent = currentValues [ " 자본총계 " ] ?: 1.0
val roe = ( niCurrent / equityCurrent ) * 100
// 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100
val debtCurrent = currentValues [ " 부채총계 " ] ?: 0.0
val debtRatio = ( debtCurrent / equityCurrent ) * 100
// 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100
val currentAssets = currentValues [ " 유동자산 " ] ?: 0.0
val currentLiabilities = currentValues [ " 유동부채 " ] ?: 1.0
val quickRatio = ( currentAssets / currentLiabilities ) * 100
return FinancialStatement (
operatingProfitGrowth = opGrowth ,
netIncomeGrowth = niGrowth ,
roe = roe ,
debtRatio = debtRatio ,
quickRatio = quickRatio ,
isOperatingProfitPositive = opCurrent > 0 ,
isNetIncomePositive = niCurrent > 0
) . apply {
println ( " 당기순이익: ${niCurrent} , isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(this)} " )
}
}
private fun extractYearlyValues ( text : String , type : String ) : Map < String , Double > {
val result = mutableMapOf < String , Double > ( )
// 핵심 수정: 항목명 뒤에 (당기) 또는 (전기)가 오고, 그 직후의 숫자(마이너스, 쉼표 포함)를 캡처
// 쉼표나 공백으로 끝나는 지점까지 찾습니다.
val regex = Regex ( """ ([가-힣\s()]+)\s\( $type \)([-0-9,.]+) """ )
regex . findAll ( text ) . forEach { match ->
val key = match . groupValues [ 1 ] . trim ( )
// 숫자 내 쉼표 제거 후 Double 변환
val rawValue = match . groupValues [ 2 ] . replace ( " , " , " " ) . toDoubleOrNull ( ) ?: 0.0
result [ key ] = rawValue
}
return result
}
}
@Serializable
data class FinancialStatement (
val revenueGrowth : Double = 0.0 , // 매출액 증가율
val operatingProfitGrowth : Double = 0.0 , // 영업이익 증가율
val netIncomeGrowth : Double = 0.0 , // 당기순이익 증가율
val roe : Double = 0.0 , // ROE
val debtRatio : Double = 0.0 , // 부채비율
val quickRatio : Double = 0.0 , // 당좌비율
val isOperatingProfitPositive : Boolean = false , // 당기 영업이익 흑자 여부
val isNetIncomePositive : Boolean = false
)