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
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-03-17 10:50:13 +09:00
import java.util.concurrent.TimeUnit
2026-01-21 18:30:03 +09:00
2026-03-16 17:07:25 +09:00
interface TradingAnalyst {
2026-03-17 10:50:13 +09:00
@SystemMessage ( """
2026-03-16 17:07:25 +09:00
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-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-03-17 10:50:13 +09:00
private val analyst = AiServices . builder ( TradingAnalyst :: class . java )
2026-03-16 17:07:25 +09:00
. 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-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-03-23 10:54:54 +09:00
if ( scores . avg ( ) > 50 ) {
2026-04-01 14:35:56 +09:00
// 2. 뉴스 스크래핑 및 학습 시간 측정
val newsIngestStartTime = System . currentTimeMillis ( )
corpInfo ?. let {
try {
NewsService . fetchAndIngestNews ( it )
} catch ( e : Exception ) { }
}
val newsIngestDuration = System . currentTimeMillis ( ) - newsIngestStartTime
println ( " ⏱️ [ $stockName ] 뉴스 수집/인덱싱 소요: ${newsIngestDuration} ms " )
2026-03-23 10:54:54 +09:00
result ( tradingDecision , false )
tradingDecision . techSummary = technicalAnalyzer . generateComprehensiveReport ( )
result ( tradingDecision , false )
2026-03-27 13:38:05 +09:00
// 4. RAG 뉴스 검색 및 임베딩 시간 측정
val ragStartTime = System . currentTimeMillis ( )
2026-03-23 10:54:54 +09:00
val question = " ${corpInfo?.cName} $stockName [ $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 ( ) }
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-03-27 13:38:05 +09:00
println ( " ✋ [ $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-03-27 13:38:05 +09:00
println ( " 🚨 [ $stockName ] 재무 안전벨트 미달 " )
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-01-21 18:30:03 +09:00
/ * *
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다 .
* /
2026-02-20 15:21:38 +09:00
// fun askWithContext(question: String,
// corpInfo: String,
// financialData: String,
// days : List<CandleData>,
// weeks : List<CandleData>,
// monthly : List<CandleData>): String {
// val questionEmbedding = embeddingModel.embed(question).content()
// val searchResult = embeddingStore.search(
// EmbeddingSearchRequest.builder()
// .queryEmbedding(questionEmbedding)
// .maxResults(5)
// .build()
// )
// val newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
//
// // 2. 종합 분석 프롬프트 구성
// val finalPrompt = """
// <|begin_of_text|><|start_header_id|>system<|end_header_id|>
// 당신은 뉴스(심리), 재무(본질), 차트(추세)를 통합 분석하는 'AI 수석 애널리스트'입니다.
// 제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요.
//
// [데이터 세트]
// 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()
//
// val response = chatModel.chat(UserMessage.from(finalPrompt))
//// println(response)
// return response.aiMessage().text()
// }
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 ) {
" 3. News Context: $validNews "
} else {
" 3. News Context: No significant news available. Rely on financials. "
}
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-03-17 10:50:13 +09:00
$ newsDataSection
2026-03-16 17:07:25 +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
)