From a700d54dfe46c8d13d5b4f19b0d09e0bb6dd5ab6 Mon Sep 17 00:00:00 2001 From: lun_admin Date: Mon, 6 Apr 2026 15:07:14 +0900 Subject: [PATCH] ... --- src/main/kotlin/Main.kt | 2 +- src/main/kotlin/network/RagService.kt | 108 +++++++++++--- src/main/kotlin/service/AutoTradingManager.kt | 44 +++--- src/main/kotlin/service/DynamicNewsScraper.kt | 140 ++++++++++-------- src/main/kotlin/service/LlamaServerManager.kt | 5 +- 5 files changed, 198 insertions(+), 101 deletions(-) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 185f72a..5ea1bf1 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -86,7 +86,7 @@ fun getLlamaBinPath(): String { } // Windows NUC os.contains("win") -> { - "$basePath/win-x64-n/llama-server.exe" + "$basePath/win-x64/llama-server.exe" } else -> "$basePath/llama-server" } diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index a1afa08..214fd14 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -46,16 +46,20 @@ import service.TradingDecisionCallback import service.UrlCacheManager import java.nio.file.Paths import java.time.Duration +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale import java.util.concurrent.TimeUnit -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 -} +//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 +//} object RagService { @@ -76,9 +80,9 @@ object RagService { .responseFormat("json_object") .build() - private val analyst = AiServices.builder(TradingAnalyst::class.java) - .chatModel(chatModel) - .build() +// private val analyst = AiServices.builder(TradingAnalyst::class.java) +// .chatModel(chatModel) +// .build() private val embeddingStore: LuceneEmbeddingStore by lazy { val path = Paths.get("db/lucene_idx") @@ -180,6 +184,28 @@ object RagService { } } + 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 // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도 + } + } + } + suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) { val totalStartTime = System.currentTimeMillis() // 전체 시작 시간 @@ -211,32 +237,68 @@ object RagService { val techDuration = System.currentTimeMillis() - techStartTime println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms") val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) - if (scores.avg() > (guideLine.times(0.85))) { + if (scores.avg() > (guideLine.times(0.50))) { // 2. 뉴스 스크래핑 및 학습 시간 측정 + 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) 실행 --- val newsIngestStartTime = System.currentTimeMillis() - corpInfo?.let { - try { - NewsService.fetchAndIngestNews(it) - } catch (e: Exception) {} + if (!hasRecentData) { + println("🌐 [$stockName] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다.") + corpInfo?.let { + try { + NewsService.fetchAndIngestNews(it) + } catch (e: Exception) { + println("❌ [$stockName] 뉴스 스크래핑 실패: ${e.message}") + } + } + } else { + println("✅ [$stockName] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다.") } val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime - println("⏱️ [$stockName] 뉴스 수집/인덱싱 소요: ${newsIngestDuration}ms") + println("⏱️ [$stockName] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration}ms") result(tradingDecision, false) tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() result(tradingDecision, false) - // 4. RAG 뉴스 검색 및 임베딩 시간 측정 - val ragStartTime = System.currentTimeMillis() - val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" - val questionEmbedding = embeddingModel.embed(question).content() - val searchResult = embeddingStore.search( + // --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 --- + // (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다) + val finalSearchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) + .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 교차 오염 방지를 위해 필터 필수 .maxResults(3) + .minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정 .build() ) - tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } + + 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() } val ragDuration = System.currentTimeMillis() - ragStartTime println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms") diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 138662d..d8ce68d 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -374,8 +374,6 @@ object AutoTradingManager { } suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { - -// if (true) return balance.holdings.forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ println("❌ 차단 처리된 주식 : ${holding.name}") @@ -641,19 +639,27 @@ object AutoTradingManager { if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } return balance } else { -// listOf("Y","X").forEach { code -> +// val now = LocalTime.now() +// val currentMinute = now.minute +// if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) { +// if (lastForceCheckMinute != currentMinute) { +// listOf("Y","X").forEach { code -> // KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { // sellingAfterMarketOnePrice(KisTradeService, it, code) // } // delay(1000) // } +// lastForceCheckMinute = currentMinute // 실행 완료 기록 +// } +// } +// } return null } suspend fun executeMarketLoop() { val balance = checkBalance() - if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } +// if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() @@ -733,16 +739,20 @@ object AutoTradingManager { } } else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) { -// if (lastForceCheckMinute != currentMinute) { -// TradingLogStore.addAnalyzer( -// " - ", -// " - ", -// "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", -// true -// ) -// checkBalance(false) -// lastForceCheckMinute = currentMinute // 실행 완료 기록 -// } + if (lastForceCheckMinute != currentMinute) { + TradingLogStore.addAnalyzer( + " - ", + " - ", + "⏰ [강제 스케줄 실행] 오후 ${now.hour}시 ${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.", + true + ) + listOf("Y","X").forEach { code -> + KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { + sellingAfterMarketOnePrice(KisTradeService, it, code) + } + } + lastForceCheckMinute = currentMinute // 실행 완료 기록 + } } } @@ -1155,7 +1165,7 @@ class TechnicalAnalyzer { val disparityDaily = (currentPrice / ma20Daily) * 100 if (disparityDaily > 115.0) { // 20일선보다 15% 이상 떠 있으면 감점 시작 - val penalty = ((disparityDaily - 115.0) * 0.5).toInt() // 초과분 1%당 2점 감점 + val penalty = ((disparityDaily - 115.0) * 0.3).toInt() // 초과분 1%당 2점 감점 short -= penalty ultra -= (penalty / 2) // 초단기에도 영향 println("⚠️ [과열 감점] 일봉 이격도(${String.format("%.1f", disparityDaily)}%): -${penalty}점") @@ -1166,8 +1176,8 @@ class TechnicalAnalyzer { if (weekly.size >= 3) { val weeklyChange = calculateChange(weekly.takeLast(3)) if (weeklyChange > 30.0) { // 3주간 30% 이상 급등 시 - mid -= 10 - short -= 5 + mid -= 6 + short -= 3 println("⚠️ [과열 감점] 주봉 급등(${String.format("%.1f", weeklyChange)}%): -10점") } } diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt index 0364fd2..34906ae 100644 --- a/src/main/kotlin/service/DynamicNewsScraper.kt +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -111,71 +111,75 @@ object DynamicNewsScraper { 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()); + // 1. 선제적 노이즈 제거: ASIDE(사이드바/광고) 태그 추가 + const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV', 'ASIDE']; + document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove()); - const MIN_LINE_LENGTH = 10; - const MIN_TOTAL_LENGTH = 100; - const CONSECUTIVE_THRESHOLD = 2; + 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; + // 2. 라인별 정제 함수 (예외 처리 강화) + const getRefinedText = (el) => { + if (!el || !el.innerText) return ""; + 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; + } + }); - 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; + 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 + ) + ); + + let finalResult = best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || ""); + + // ⭐ 5. Fallback 로직: 스마트 추출에 실패했거나 너무 짧으면, 노이즈가 제거된 body 원문을 통째로 반환 + if (!finalResult || finalResult.length < MIN_TOTAL_LENGTH) { + finalResult = getRefinedText(document.body); } - }); - // 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비) - if (consecutiveShort < CONSECUTIVE_THRESHOLD) { - resultLines = resultLines.concat(tempBuffer); + return finalResult; } - 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() + """.trimIndent() return page.evaluate(script) as String } @@ -259,8 +263,26 @@ object DynamicNewsScraper { } private fun cleanText(text: String): String { - return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거 - .replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거 + // 1. 제거할 불필요한 노이즈 키워드 목록 + val noiseKeywords = listOf( + "기자", "무단 전재", "재배포 금지", "Copyright", "ⓒ", + "ADVERTISEMENT", "돌아가기", "topstarnews", + "구독하기", "제보하기", "광고", "Sponsored" + ) + + var cleanedText = text + + // 2. 키워드가 포함된 '한 줄 전체'를 삭제 + for (keyword in noiseKeywords) { + // (?im): 대소문자 무시(i) 및 다중행 모드(m) + // 해당 키워드가 포함된 라인을 찾아서 빈 문자열로 치환 + val regex = Regex("(?im)^.*${Regex.escape(keyword)}.*\$") + cleanedText = cleanedText.replace(regex, "") + } + + // 3. 연속된 빈 줄(엔터)들을 하나의 줄바꿈으로 압축하여 RAG 청크 품질 향상 + return cleanedText + .replace(Regex("\\n{2,}"), "\n") .trim() } } @@ -286,7 +308,9 @@ object SafeScraper { try { withTimeout(25000L) { // 타임아웃 약간 증가 val content = DynamicNewsScraper.fetchFullContent(item.originallink) + println("📝 [News Raw Text Length] ${item.originallink.take(30)}... : ${content.length}자 추출됨") if (content.isNotBlank() && content.length > 100) { + UrlCacheManager.addToCache(item.originallink) RagService.ingestWithChunking( text = content, newsLink = item.originallink, diff --git a/src/main/kotlin/service/LlamaServerManager.kt b/src/main/kotlin/service/LlamaServerManager.kt index 87c1e50..dac82b2 100644 --- a/src/main/kotlin/service/LlamaServerManager.kt +++ b/src/main/kotlin/service/LlamaServerManager.kt @@ -114,7 +114,7 @@ object LlamaServerManager { val pb = ProcessBuilder(command) // 2. 윈도우 Vulkan 환경 변수 설정 - if (isWin && binPath.endsWith("win-x64")) { + if (isWin ) { val env = pb.environment() // 특정 GPU 선택 (내장 GPU가 여러 개일 경우) env["GGML_VULKAN_DEVICE"] = "0" @@ -158,10 +158,11 @@ object LlamaServerManager { } if (port == EMBEDDING_PORT){ AutoTradingManager.llmNews = true + RagService.active() } if (processes.size > 1) { println("[Cache] ${processes.size}") - RagService.active() + } } }