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-04-08 14:18:09 +09:00
import analyzer.AdvancedTradeAssistant
2026-04-07 17:32:21 +09:00
import analyzer.FinancialAnalyzer
import analyzer.FinancialMapper
import analyzer.FinancialStatement
import analyzer.InvestmentScores
2026-04-07 18:07:18 +09:00
import analyzer.ScalpingSignalModel
2026-04-07 17:32:21 +09:00
import analyzer.TechnicalAnalyzer
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-04-15 10:53:40 +09:00
import dev.langchain4j.store.embedding.EmbeddingSearchResult
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
2026-04-07 17:32:21 +09:00
import kotlinx.serialization.json.double
2026-03-17 10:50:13 +09:00
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-04-16 15:48:23 +09:00
import model.TradingDecision
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-04-07 17:32:21 +09:00
import service.InvestmentGrade
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-04-08 14:18:09 +09:00
import java.util.concurrent.ConcurrentHashMap
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-04-08 14:18:09 +09:00
val isSafetyBeltStockCodes = ConcurrentHashMap . newKeySet < String > ( )
// (매일 아침 8시 30분 시스템 초기화 시 호출해주어야 함)
fun clearDailyCache ( ) {
isSafetyBeltStockCodes . clear ( )
println ( " 🧹 [System] 일일 재무 미달 캐시 초기화 완료 " )
}
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-04-07 17:32:21 +09:00
private fun isVeryRecentNews ( dateStr : String ? , maxHours : Long = 1 ) : Boolean {
if ( dateStr . isNullOrBlank ( ) ) return false
return try {
val formatter = DateTimeFormatter . ofPattern ( " EEE, dd MMM yyyy HH:mm:ss Z " , Locale . ENGLISH )
val pubDate = ZonedDateTime . parse ( dateStr , formatter )
val now = ZonedDateTime . now ( )
// 현재 시간과 뉴스 발행 시간의 차이를 시간 단위로 계산
val hoursDiff = Math . abs ( ChronoUnit . HOURS . between ( pubDate , now ) )
hoursDiff < maxHours // 1시간 미만이면 true
} catch ( e : Exception ) {
false
}
}
2026-03-27 13:38:05 +09:00
suspend fun processStock ( currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer , stockName : String , stockCode : String , result : TradingDecisionCallback ) {
2026-04-07 17:32:21 +09:00
val totalStartTime = System . currentTimeMillis ( )
2026-03-27 13:38:05 +09:00
2026-01-22 16:21:18 +09:00
coroutineScope {
2026-02-03 18:07:18 +09:00
try {
2026-04-07 17:32:21 +09:00
val tradingDecision = TradingDecision ( ) . apply {
this . stockCode = stockCode
this . analyzer = technicalAnalyzer
this . currentPrice = currentPrice
}
2026-04-08 14:18:09 +09:00
if ( isSafetyBeltStockCodes . contains ( stockCode ) ) {
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
result ( tradingDecision . apply { decision = " HOLD " ; reason = " 재무 안정성 부족 (캐시) " } , false )
return @coroutineScope
}
2026-04-07 17:32:21 +09:00
// [1단계] 재무 분석 및 필터링 (가장 빠름)
val finStartTime = System . currentTimeMillis ( )
val financialData = NewsService . fetchFinancialGrowth ( DartCodeManager . getCorpCode ( stockCode ) ?. cCode )
val financialStmt = FinancialMapper . mapRawTextToStatement ( financialData )
val finDuration = System . currentTimeMillis ( ) - finStartTime
println ( " financialStmt ${FinancialAnalyzer.toString(financialStmt)} isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(financialStmt)} " )
// [2단계] 기술적 지표 및 과열 체크
val techStartTime = System . currentTimeMillis ( )
val financialScore = FinancialAnalyzer . calculateScore ( financialStmt )
val scores = technicalAnalyzer . calculateScores ( financialScore )
2026-04-07 18:07:18 +09:00
tradingDecision . signalModel = technicalAnalyzer . generateComprehensiveSignal ( )
2026-04-07 17:32:21 +09:00
val techDuration = System . currentTimeMillis ( ) - techStartTime
2026-04-07 18:07:18 +09:00
println ( " techSignal.compositeScore ${tradingDecision.signalModel} " )
2026-04-07 17:32:21 +09:00
if ( ! FinancialAnalyzer . isSafetyBeltMet ( financialStmt ) ) {
logTime ( stockName , " 재무 미달 조기 종료 " , finDuration , System . currentTimeMillis ( ) - totalStartTime )
result ( tradingDecision . apply { decision = " HOLD " ; reason = " 재무 안정성 부족 " } , false )
2026-04-08 14:18:09 +09:00
isSafetyBeltStockCodes . add ( stockCode )
2026-04-07 17:32:21 +09:00
return @coroutineScope
}
2026-04-07 18:07:18 +09:00
if ( ( tradingDecision . signalModel ?. compositeScore ?: 0 ) < 50 ) {
2026-04-07 17:32:21 +09:00
logTime ( stockName , " 기술 점수 미달 조기 종료 " , techDuration , System . currentTimeMillis ( ) - totalStartTime )
2026-04-08 14:18:09 +09:00
if ( FinancialAnalyzer . isBuyConsiderationMet ( financialStmt ) && financialScore > 70 ) {
TradingLogStore . addAnalyzer ( stockName , stockCode , " 매수 타점 미도달 (재무 우량주로 감시 지속) " , true )
result ( tradingDecision . apply {
decision = " RETRY " // 콜백에서 "BUY"가 아니므로 HOLD와 동일하게 취급됨
reason = " 매수 타점 미도달 (재무 우량주로 감시 지속) "
confidence = 65.0 // AutoTradingManager의 재분석 기준(60.0)을 넘기기 위해 부여
} , true ) // isSuccess를 true로 주어야 콜백이 무시하지 않음
} else {
result ( tradingDecision . apply { decision = " HOLD " ; reason = " 매수 타점 미도달 " } , false )
}
2026-04-07 17:32:21 +09:00
return @coroutineScope
2026-02-10 15:08:52 +09:00
}
2026-04-07 17:32:21 +09:00
// [3단계] 뉴스 RAG 및 AI 분석 (가장 오래 걸림)
val ragStartTime = System . currentTimeMillis ( )
// 1시간 이내 뉴스 존재 여부 확인 후 동적 스크래핑
checkAndFetchRecentNews ( stockName , stockCode )
val question = " $stockName 실적 및 향후 전망 "
val questionEmbedding = embeddingModel . embed ( question ) . content ( )
2026-04-15 10:53:40 +09:00
var finalSearchResult : EmbeddingSearchResult < TextSegment > ? = null
try {
finalSearchResult = embeddingStore . search (
EmbeddingSearchRequest . builder ( )
. queryEmbedding ( questionEmbedding )
. filter ( MetadataFilterBuilder . metadataKey ( " stockCode " ) . isEqualTo ( stockCode ) )
. maxResults ( 10 ) // 최신 뉴스 3개 적정
. minScore ( 0.2 )
. build ( )
)
} catch ( e : Exception ) { }
2026-04-07 17:32:21 +09:00
// 3. 검색된 내용을 하나의 문자열로 합쳐서 전달
2026-04-15 10:53:40 +09:00
tradingDecision . newsContext = finalSearchResult ?. matches ( ) ?. distinct ( ) // 중복 제거
?. take ( 4 ) // 10개에서 4개로 축소
?. joinToString ( " \n \n " ) {
2026-04-08 14:18:09 +09:00
it . embedded ( ) . text ( )
}
2026-04-07 17:32:21 +09:00
val finalDecision = decideTrading ( stockName , scores , financialStmt , tradingDecision )
val ragAiDuration = System . currentTimeMillis ( ) - ragStartTime
// [4단계] 최종 로그 기록
val totalDuration = System . currentTimeMillis ( ) - totalStartTime
val detailLog = " 재무( ${finDuration} ms), 기술( ${techDuration} ms), 뉴스/AI( ${ragAiDuration} ms), 전체( ${totalDuration} ms) "
TradingLogStore . addAnalyzer ( stockName , stockCode , detailLog , true )
println ( " $stockName [ $stockCode ] $detailLog " )
result ( finalDecision , true )
2026-03-27 13:38:05 +09:00
} catch ( e : Exception ) {
2026-04-07 17:32:21 +09:00
println ( " ❌ [ $stockName ] 분석 실패: ${e.message} " )
}
}
}
private suspend fun checkAndFetchRecentNews ( stockName : String , stockCode : String ) {
val question = " $stockName 실적 전망 및 최근 이슈 "
val questionEmbedding = embeddingModel . embed ( question ) . content ( )
// 1. 벡터 DB에서 해당 종목의 뉴스 검색
2026-04-15 10:53:40 +09:00
var searchResult : EmbeddingSearchResult < TextSegment > ? = null
try {
searchResult = embeddingStore . search (
EmbeddingSearchRequest . builder ( )
. queryEmbedding ( questionEmbedding )
. filter ( MetadataFilterBuilder . metadataKey ( " stockCode " ) . isEqualTo ( stockCode ) )
. maxResults ( 10 )
. minScore ( 0.2 )
. build ( )
)
} catch ( e : Exception ) {
}
2026-04-07 17:32:21 +09:00
// 2. 검색된 뉴스 중 1시간 이내(Very Recent) 데이터가 있는지 확인
2026-04-15 10:53:40 +09:00
val hasHotNews = searchResult ?. matches ( ) ?. any { match ->
2026-04-07 17:32:21 +09:00
val pubDate = match . embedded ( ) . metadata ( ) . getString ( " date " )
isVeryRecentNews ( pubDate , maxHours = 1 )
2026-04-15 10:53:40 +09:00
} ?: false
2026-04-07 17:32:21 +09:00
// 3. 최신 뉴스가 없다면 네이버 API 및 Playwright 스크래핑 가동
if ( ! hasHotNews ) {
println ( " 🌐 [ $stockName ] 최근 1시간 내 분석된 뉴스가 없습니다. 실시간 스크래핑을 시작합니다. " )
val corpInfo = DartCodeManager . getCorpCode ( stockCode )
corpInfo ?. let {
try {
// NewsService에서 오늘자 뉴스를 가져와 인덱싱 수행
NewsService . fetchAndIngestNews ( it )
} catch ( e : Exception ) {
println ( " ❌ [ $stockName ] 뉴스 업데이트 실패: ${e.message} " )
}
2026-02-03 18:07:18 +09:00
}
2026-04-07 17:32:21 +09:00
} else {
println ( " ✅ [ $stockName ] 최근 1시간 내 기사가 DB에 존재하여 스크래핑을 건너뜁니다. " )
2026-01-21 18:30:03 +09:00
}
}
2026-04-07 17:32:21 +09:00
// 시간 기록용 헬퍼 함수
private fun logTime ( name : String , status : String , stepMs : Long , totalMs : Long ) {
println ( " ⏱️ [ $name ] $status - 단계: ${stepMs} ms / 누적: ${totalMs} ms " )
}
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-04-07 17:32:21 +09:00
put ( " max_tokens " , 200 )
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-04-07 17:32:21 +09:00
val totalStartTime = System . currentTimeMillis ( ) // 전체 시작 시간
2026-03-17 10:50:13 +09:00
2026-04-07 17:32:21 +09:00
// 1-1. 재무 점수 산출 시간 측정
val finStartTime = System . currentTimeMillis ( )
val finScore100 = FinancialAnalyzer . calculateScore ( financialStmt ) . toDouble ( )
val finDuration = System . currentTimeMillis ( ) - finStartTime
// 1-2. 기술 분석 및 리포트 생성 시간 측정
val techStartTime = System . currentTimeMillis ( )
2026-04-07 18:07:18 +09:00
val techScore100 = tempDecision . signalModel ?. compositeScore ?. toDouble ( ) ?: 0.0
2026-04-07 17:32:21 +09:00
val isOverheated = tempDecision . analyzer ?. isOverheatedStock ( ) ?: false
tempDecision . techSummary = tempDecision . analyzer ?. generateComprehensiveReport ( finScore100 . toInt ( ) )
val techDuration = System . currentTimeMillis ( ) - techStartTime
// 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간)
val newsStartTime = System . currentTimeMillis ( )
val ( newsScore100 , newsReason ) = tempDecision . newsContext ?. let {
2026-04-08 14:18:09 +09:00
getAiNewsScore ( stockName , it , tempDecision . techSummary ?: " " )
2026-04-07 17:32:21 +09:00
} ?: ( 50.0 to " 참조 뉴스 없음 " )
val newsDuration = System . currentTimeMillis ( ) - newsStartTime
// 1-4. 시스템 및 가중치 합성 시간 측정
val synthStartTime = System . currentTimeMillis ( )
val sysScore100 = calculateSystemPoint ( scores ) * 4.0
// 가중치 합성 (Tech 35% : Fin 25% : News 20% : Sys 20%)
var finalConfidence = ( finScore100 * 0.25 ) + ( techScore100 * 0.25 ) + ( newsScore100 * 0.30 ) + ( sysScore100 * 0.20 )
// var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.35) + (newsScore100 * 0.20) + (sysScore100 * 0.20)
// 보너스 및 패널티 로직
if ( finScore100 >= 80.0 && techScore100 >= 70.0 ) finalConfidence += 8.0
if ( techScore100 >= 90.0 && finScore100 >= 50.0 ) finalConfidence += 5.0
if ( isOverheated ) finalConfidence *= 0.85
val totalScore = ( scores . ultraShort + scores . shortTerm + scores . midTerm + scores . longTerm ) / 4.0
2026-04-08 14:18:09 +09:00
tempDecision . ultraShortScore = scores . ultraShort . toDouble ( )
tempDecision . shortTermScore = scores . shortTerm . toDouble ( )
tempDecision . midTermScore = scores . midTerm . toDouble ( )
tempDecision . longTermScore = scores . longTerm . toDouble ( )
2026-04-07 17:32:21 +09:00
// 5. 최종 결정 및 사유 정리
val minScore = KisSession . config . getValues ( ConfigIndex . MIN _PURCHASE _SCORE _INDEX )
var finalDecision = " HOLD "
var finalReason = " "
2026-04-08 14:18:09 +09:00
var grade = AutoTradingManager . getInvestmentGrade ( tempDecision , totalScore , finalConfidence , finScore100 )
var assistantReason = " "
if ( grade != InvestmentGrade . LEVEL _0 _SPECULATIVE ) {
val advice = AdvancedTradeAssistant . confirmTrade (
currentGrade = grade ,
currentPrice = tempDecision . currentPrice ,
min30 = tempDecision . analyzer ?. min30 ?: emptyList ( ) ,
daily = tempDecision . analyzer ?. daily ?: emptyList ( )
)
finalConfidence += advice . confidenceBonus
if ( ! advice . isConfirmed ) {
grade = InvestmentGrade . LEVEL _0 _SPECULATIVE
assistantReason = " 🚫 [어시스턴트 차단] ${advice.reason} "
} else if ( advice . reason . isNotEmpty ( ) ) { // 💡 조건 변경
assistantReason = " ➕ [확인됨: ${advice.reason} ] "
}
}
val synthDuration = System . currentTimeMillis ( ) - synthStartTime
2026-04-07 17:32:21 +09:00
when {
newsScore100 < 30.0 -> {
finalDecision = " HOLD "
finalReason = " 📉 뉴스 악재 감지: $newsReason "
2026-03-18 16:01:42 +09:00
}
2026-04-07 17:32:21 +09:00
isOverheated && finalConfidence < 85.0 -> {
finalDecision = " HOLD "
finalReason = " 🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한 "
}
2026-04-08 14:18:09 +09:00
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade . LEVEL _0 _SPECULATIVE -> {
2026-04-07 17:32:21 +09:00
finalDecision = " BUY "
2026-04-08 14:18:09 +09:00
finalReason = " ✅ [ ${grade.displayName} ] $newsReason | 종합 지표 우수 | $assistantReason "
2026-04-07 17:32:21 +09:00
}
finalConfidence < 40.0 -> {
finalDecision = " SELL "
finalReason = " ⚠️ 종합 지표 악화로 인한 비중 축소 권장 "
}
else -> {
finalDecision = " HOLD "
finalReason = " ⏳ 지표 중립 또는 확신 부족 (신뢰도: ${String.format("%.1f", finalConfidence)} ) "
}
}
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
val totalDuration = System . currentTimeMillis ( ) - totalStartTime
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
// 성능 분석 로그 출력 (CSV 형태로 출력하여 나중에 엑셀 분석 가능)
println ( " ⏱️ [ $stockName ] 처리 성능 리포트: 전체 ${totalDuration} ms | 재무 ${finDuration} ms | 기술 ${techDuration} ms | 뉴스AI ${newsDuration} ms | 합성 ${synthDuration} ms " )
2026-01-22 16:21:18 +09:00
2026-04-07 17:32:21 +09:00
return TradingDecision ( ) . apply {
2026-04-08 14:18:09 +09:00
this . technicalScore = techScore100
this . financialScore = finScore100
this . systemScore = sysScore100
2026-04-07 17:32:21 +09:00
this . stockCode = tempDecision . stockCode
this . stockName = stockName
this . currentPrice = tempDecision . currentPrice
this . techSummary = tempDecision . techSummary
this . ultraShortScore = scores . ultraShort . toDouble ( )
this . shortTermScore = scores . shortTerm . toDouble ( )
this . midTermScore = scores . midTerm . toDouble ( )
this . longTermScore = scores . longTerm . toDouble ( )
this . reason = finalReason
this . decision = finalDecision
this . confidence = finalConfidence
this . investmentGrade = grade
this . newsScore = newsScore100
this . newsContext = tempDecision . newsContext
this . financialData = tempDecision . financialData
} . apply {
if ( confidence > 50.0 ) {
println ( this . toString ( ) )
}
}
}
2026-01-23 17:05:09 +09:00
2026-03-17 10:50:13 +09:00
2026-04-08 14:18:09 +09:00
private suspend fun getAiNewsScore ( stockName : String , news : String , techSummary : String ) : Pair < Double , String > {
2026-04-07 17:32:21 +09:00
val prompt = """
# Role : Expert Quantitative & Sentiment Analyst
2026-04-08 14:18:09 +09:00
# Target Stock : [ $ stockName ]
2026-04-07 17:32:21 +09:00
# Task : Evaluate [ News Text ] by correlating it with [ Market Context ] .
# Input 1 : [ Market Context ] ( Standardized Scores & Price History )
$ techSummary
# Input 2 : [ News Text ] ( Latest Headlines & Content )
$ news
# Evaluation Logic ( Internal Reasoning ) :
1. Value Gap : Compare ' Financial Score ' with ' Base Position ' . ( e . g . , High Score + Low Position = Strong Buy )
2. Momentum Catalyst : Check if News justifies the ' Volume Intensity ' and ' Weekly Breakout ' .
3. Sentiment Weight :
- Positive : Earnings surprise , Contract win , Turnaround , Buyback . ( + 10 ~ 30 )
- Negative : Deficit , Lawsuit , Capital increase ( Dilution ) . ( - 20 ~ 40 )
# Operational Instructions :
- If the news mentions specific profit figures ( e . g . , " 72B KRW " ) , award a " Profit Bonus " even without YoY comparison .
- If ' Base Position ' is near 100 % ( at 120 MA ) , consider it a ' Safe Entry ' for long - term holding .
# Constraints :
2026-04-08 14:18:09 +09:00
- Reason : KOREAN only , max 50 chars . Explain the " Synergy " between scores and news .
1. Target Isolation : You MUST ONLY extract facts related EXACTLY to [ $ stockName ] .
2. No Mix - up : Do NOT attribute actions of other companies ( e . g . , unrelated capital increases or earnings of competitors ) to [ $ stockName ] .
3. Verify Numbers : Check if [ $ stockName ] ' s YoY profit is Positive ( + ) or Negative ( - ) . If Negative , you MUST reflect it as a penalty in the score .
2026-04-07 17:32:21 +09:00
- Output : Strictly JSON format .
# JSON Output :
{
" score " : [ 0.0 - 100.0 ] ,
" reason " : " [KOREAN_REASON] "
}
""" .trimIndent()
2026-01-23 17:05:09 +09:00
2026-04-07 17:32:21 +09:00
return try {
val raw = callLlamaWithSchema ( prompt )
println ( " getAiNewsScore $raw " )
2026-04-09 13:17:52 +09:00
// 💡 [핵심 해결책] 마크다운 블록 및 앞뒤 쓸데없는 텍스트 완벽 차단
val startIndex = raw . indexOf ( " { " )
val endIndex = raw . lastIndexOf ( " } " )
// '{' 부터 '}' 까지만 정확하게 잘라냄
val sanitizedRaw = if ( startIndex != - 1 && endIndex != - 1 ) {
raw . substring ( startIndex , endIndex + 1 )
} else {
raw // 중괄호를 못 찾았을 경우 대비 (기본 fallback)
}
// 정제된 문자열로 JSON 파싱
val json = Json { ignoreUnknownKeys = true } . parseToJsonElement ( sanitizedRaw ) . jsonObject
2026-03-17 10:50:13 +09:00
2026-04-07 17:32:21 +09:00
val score = json [ " score " ] ?. jsonPrimitive ?. double ?: 50.0
val reason = json [ " reason " ] ?. jsonPrimitive ?. content ?: " 뉴스 분석 완료 "
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
score to reason
} catch ( e : Exception ) {
50.0 to " 뉴스 분석 오류 발생 (중립 처리) "
}
}
2026-01-22 16:21:18 +09:00
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
// 재무 점수 계산 (Max 40)
private fun calculateFinancialPoint ( fs : FinancialStatement ) : Double {
var p = 0.0
// 영업이익 흑자면 25점, 적자여도 성장 중이면 10점
p += if ( fs . isOperatingProfitPositive ) 25.0 else ( if ( fs . operatingProfitGrowth > 30 ) 10.0 else 0.0 )
// ROE 10% 기준 비례 배분 (Max 10)
p += ( fs . roe / 15.0 * 10.0 ) . coerceIn ( 0.0 , 10.0 )
// 부채비율 100% 이하면 5점 만점
p += if ( fs . debtRatio <= 100.0 ) 5.0 else ( 150.0 - fs . debtRatio ) . coerceAtLeast ( 0.0 ) / 10.0
return p
}
2026-03-18 16:01:42 +09:00
2026-04-07 17:32:21 +09:00
private fun calculateSystemPoint ( s : InvestmentScores ) : Double {
val midLongAvg = ( s . midTerm + s . longTerm ) / 2.0
val base = ( midLongAvg / 100.0 * 15.0 ) + ( s . ultraShort / 100.0 * 10.0 )
// 정배열 보너스: 초단기 > 단기 > 중기 점수 순서일 때 가점
val alignmentBonus = if ( s . ultraShort > s . shortTerm && s . shortTerm > s . midTerm ) 3.0 else 0.0
return ( base + alignmentBonus ) . coerceIn ( 0.0 , 25.0 )
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