From d89d793efab7bce2d013a4bbcee706342563a7fa Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 23 Jan 2026 17:05:09 +0900 Subject: [PATCH] .... --- build.gradle.kts | 3 +- src/main/kotlin/Main.kt | 2 +- src/main/kotlin/model/ChartModels.kt | 15 +- src/main/kotlin/network/DartCodeManager.kt | 16 +- src/main/kotlin/network/KisTradeService.kt | 4 +- src/main/kotlin/network/NewsService.kt | 90 +++---- src/main/kotlin/network/RagService.kt | 251 +++++++++++++----- src/main/kotlin/service/AutoTradingManager.kt | 124 ++++++--- src/main/kotlin/service/DynamicNewsScraper.kt | 160 +++++++++++ .../LlamaServerManager.kt | 18 +- src/main/kotlin/service/UrlCacheManager.kt | 55 ++++ src/main/kotlin/ui/AiAnalysisView.kt | 15 +- src/main/kotlin/ui/BalanceSection.kt | 8 +- src/main/kotlin/ui/DashboardScreen.kt | 18 +- src/main/kotlin/ui/IntegratedOrderSection.kt | 82 +++--- src/main/kotlin/ui/StockDetailArea.kt | 60 +++-- 16 files changed, 665 insertions(+), 256 deletions(-) create mode 100644 src/main/kotlin/service/DynamicNewsScraper.kt rename src/main/kotlin/{network => service}/LlamaServerManager.kt (82%) create mode 100644 src/main/kotlin/service/UrlCacheManager.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7b056c2..cd1770c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,7 +33,8 @@ dependencies { // Database (Exposed & SQLite) // H2 Database (네이티브 라이브러리 없는 순수 자바 DB) implementation("com.h2database:h2:2.2.224") - +// Source: https://mvnrepository.com/artifact/com.microsoft.playwright/playwright + implementation("com.microsoft.playwright:playwright:1.57.0") implementation("io.ktor:ktor-client-websockets:${ktorVersion}") // SQL 프레임워크 (Exposed) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 6d77b38..76ca91a 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -21,7 +21,7 @@ import org.jetbrains.exposed.sql.transactions.transaction import model.AppConfig import model.KisSession import network.DartCodeManager -import network.LlamaServerManager +import service.LlamaServerManager import network.NewsService import org.jetbrains.exposed.sql.selectAll import ui.DashboardScreen diff --git a/src/main/kotlin/model/ChartModels.kt b/src/main/kotlin/model/ChartModels.kt index 6c119f8..ee18566 100644 --- a/src/main/kotlin/model/ChartModels.kt +++ b/src/main/kotlin/model/ChartModels.kt @@ -25,7 +25,20 @@ data class CandleData( val stck_prpr: String, // 현제가 val cntg_vol: String, val acml_tr_pbmn: String, -) +) { + override fun toString(): String { + return """ +stck_cntg_hour : $stck_cntg_hour +stck_bsop_date : $stck_bsop_date +stck_oprc : $stck_oprc +stck_hgpr : $stck_hgpr +stck_lwpr : $stck_lwpr +stck_prpr : $stck_prpr +cntg_vol : $cntg_vol +acml_tr_pbmn : $acml_tr_pbmn + """.trimIndent() + } +} @Serializable data class OverseasCandleData( val o_sign: String = "", // 대비 기호 diff --git a/src/main/kotlin/network/DartCodeManager.kt b/src/main/kotlin/network/DartCodeManager.kt index 6a04f4f..8314f77 100644 --- a/src/main/kotlin/network/DartCodeManager.kt +++ b/src/main/kotlin/network/DartCodeManager.kt @@ -8,8 +8,15 @@ import java.io.File import java.util.zip.ZipInputStream import javax.xml.parsers.DocumentBuilderFactory + +data class CorpInfo( + var cCode : String = "", + var cName : String = "", + var stockCode : String = "", + var stockName : String = "", +) object DartCodeManager { - private val corpCodeMap = mutableMapOf() + private val corpCodeMap = mutableMapOf() private const val DART_API_KEY = "61143d2af0759f6c28ce372d9e339d1e01687abc" // 지범님의 API 키 입력 private fun saveXmlDebugFile(xmlBytes: ByteArray) { @@ -63,10 +70,11 @@ object DartCodeManager { val element = nodeList.item(i) as org.w3c.dom.Element val stockCode = element.getElementsByTagName("stock_code").item(0)?.textContent?.trim() ?: "" val corpCode = element.getElementsByTagName("corp_code").item(0)?.textContent ?: "" - println("stockCode: $stockCode , corpCode: $corpCode") + val corpName = element.getElementsByTagName("corp_name").item(0)?.textContent ?: "" +// println("[$corpName]stockCode: $stockCode , corpCode: $corpCode") // 종목코드(stock_code)가 있는 상장사만 매핑에 추가 if (stockCode.isNotEmpty()) { - corpCodeMap[stockCode] = corpCode + corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode) } } } @@ -74,7 +82,7 @@ object DartCodeManager { /** * 6자리 종목코드로 8자리 법인코드 반환 */ - fun getCorpCode(stockCode: String): String? { + fun getCorpCode(stockCode: String): CorpInfo? { // 1. 직접 매칭 시도 corpCodeMap[stockCode]?.let { return it } diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index da4a165..af93c59 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -220,12 +220,12 @@ object KisTradeService { val body = response.body() val output2 = body["output2"]?.jsonArray - + println("output2 ${output2}") val candles = output2?.map { element -> val obj = element.jsonObject CandleData( stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", - stck_prpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가 + stck_prpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가 stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0", stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0", diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index dd3bbde..d2b743c 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -15,65 +15,56 @@ import io.ktor.client.request.parameter import io.ktor.http.ContentType.Application.Json import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import model.CorpInfo import model.DartFinancialResponse import model.NaverNewsResponse +import service.DynamicNewsScraper +import service.SafeScraper +import service.UrlCacheManager object NewsService { private val client = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.ALL - } + + } + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL } } - suspend fun fetchAndIngestNews(query: String) { + suspend fun fetchAndIngestNews(corpInfo: CorpInfo) { val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요 val clientSecret = "DODCxb1M4Z" - - try { - val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { - parameter("query", query) - parameter("display", 10) // 최근 10개 뉴스 - parameter("sort", "sim") // 유사도 순 (또는 date 발간순) - header("X-Naver-Client-Id", clientId) - header("X-Naver-Client-Secret", clientSecret) - }.body() - - response.items.forEach { item -> - // HTML 태그 제거 및 텍스트 정제 - val cleanTitle = item.title.replace(Regex("<[^>]*>"), "") - val cleanDesc = item.description.replace(Regex("<[^>]*>"), "") - val fullText = "[$cleanTitle] $cleanDesc" - println(fullText) - // RAG 서비스에 학습(Ingest) 시키기 - RagService.ingest( - text = fullText, - newsLink = item.originallink, - pubDate = item.pubDate - ) + var qlist = listOf("${corpInfo.stockName} 분석","${corpInfo.stockName}[${corpInfo.stockCode}]", "${corpInfo.cName} 최근 동향", "${corpInfo.cName}") + qlist.forEach { query -> + try { + val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { + parameter("query", query) + parameter("display", 3) // 최근 10개 뉴스 + parameter("sort", "sim") // 유사도 순 (또는 date 발간순) + header("X-Naver-Client-Id", clientId) + header("X-Naver-Client-Secret", clientSecret) + }.body() + SafeScraper.scrapeParallel(corpInfo,response.items) + } catch (e: Exception) { + println("❌ 뉴스 가져오기 실패: ${e.message}") } - println("📰 '${query}' 관련 뉴스 10개 학습 완료") - } catch (e: Exception) { - println("❌ 뉴스 가져오기 실패: ${e.message}") } } - suspend fun fetchCorpInfo(corpCode: String): String { - val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc" - val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode" - - return try { - val response = client.get(url).body() - "기업명: ${response.corp_name}, 주요사업: ${response.main_business}" - } catch (e: Exception) { - "기업 정보 로드 실패" - } - } +// suspend fun fetchCorpInfo(corpCode: String): String { +// val apiKey = "61143d2af0759f6c28ce372d9e339d1e01687abc" +// val url = "https://opendart.fss.or.kr/api/company.json?crtfc_key=$apiKey&corp_code=$corpCode" +// +// return try { +// val response = client.get(url).body() +// "기업명: ${response.corp_name}, 주요사업: ${response.main_business}" +// } catch (e: Exception) { +// "기업 정보 로드 실패" +// } +// } suspend fun fetchFinancialGrowth(corpCode: String?): String { if (corpCode != null) { @@ -84,15 +75,12 @@ object NewsService { return try { val response = client.get(url).body() val accounts = response.list ?: return "재무 데이터 없음" - - val revenue = accounts.find { it.account_nm == "매출액" } - val opProfit = accounts.find { it.account_nm == "영업이익" } - - """ - [재무 분석 데이터] - - 매출액: (당기)${revenue?.thstrm_amount}, (전기)${revenue?.frmtrm_amount} - - 영업이익: (당기)${opProfit?.thstrm_amount}, (전기)${opProfit?.frmtrm_amount} - """.trimIndent() + var buffer : StringBuffer = StringBuffer() + buffer.append("[재무 분석 데이터]") + response.list.forEach { it + buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n") + } + return buffer.toString() } catch (e: Exception) { "재무 API 연동 실패: ${e.message}" } diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 72cee77..f05f792 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -7,22 +7,23 @@ import dev.langchain4j.data.segment.TextSegment import dev.langchain4j.model.openai.OpenAiChatModel import dev.langchain4j.model.openai.OpenAiEmbeddingModel import dev.langchain4j.store.embedding.EmbeddingSearchRequest +import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import model.CandleData import network.DartCodeManager -import network.KisTradeService import network.NewsService import org.apache.lucene.store.MMapDirectory -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction import service.TechnicalAnalyzer +import service.TradingDecisionCallback +import service.UrlCacheManager import java.nio.file.Paths import java.time.Duration object RagService { + // 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정 private val embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("http://127.0.0.1:8081/v1") @@ -32,6 +33,7 @@ object RagService { private val chatModel = OpenAiChatModel.builder() .baseUrl("http://127.0.0.1:8080/v1") .apiKey("unused") + .temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도 .timeout(Duration.ofSeconds(60)) .build() @@ -45,40 +47,104 @@ object RagService { LuceneEmbeddingStore.builder() .directory(directory) .build() + } + + fun active() { + println("[Cache] Active") + if (UrlCacheManager.isInitialized()) return + println("[Cache] initialize") + UrlCacheManager.initialize(embeddingStore, embeddingModel) + } + + /** * 텍스트를 임베딩하여 H2 DB에 저장합니다. */ - fun ingest(text: String, newsLink: String = "", pubDate: String = "") { - // 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성 - val metadata = Metadata() - metadata.put("link", newsLink) - metadata.put("date", pubDate) + fun ingestWithChunking( + text: String, + newsLink: String = "", + pubDate: String = "", + stcokName: String, + corpCode: String, + corpName: String, + stockCode: String + ) { + val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정 - // TextSegment.from(text, metadata) 팩토리 메서드 활용 - val segment = TextSegment.from(text, metadata) - val embedding = embeddingModel.embed(segment).content() + // 1. 문단 단위로 먼저 분리 + val paragraphs = text.split(Regex("\n\n+")) + val chunks = mutableListOf() + var currentChunk = StringBuilder() - // LuceneEmbeddingStore.add(Embedding, TextSegment) 호출 - embeddingStore.add(embedding, segment) - println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...") + 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) + } + } + println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료") } - suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) { + object JsonSanitizer { + fun formatJson(raw: String): String { + val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL) + return raw.trim() + .removePrefix("```json") + .removePrefix("```") + .removeSuffix("```") + .trim() + } + } + + suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) { // 1. 10분간의 데이터 가져오기 (API 호출) coroutineScope { var tradingDecision : TradingDecision = TradingDecision() - val corpCode = DartCodeManager.getCorpCode(stockCode) - val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) } + tradingDecision.stockCode = stockCode + var corpInfo = DartCodeManager.getCorpCode(stockCode) + corpInfo?.stockName = stockName + corpInfo?.let { NewsService.fetchAndIngestNews(it) } + + val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } tradingDecision.financialData = financialDataDeferred.await() - result(tradingDecision.toString(),false) + result(tradingDecision,false) tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() - result(tradingDecision.toString(),false) + result(tradingDecision,false) - val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘." + val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" val questionEmbedding = embeddingModel.embed(question).content() val searchResult = embeddingStore.search( EmbeddingSearchRequest.builder() @@ -87,12 +153,28 @@ object RagService { .build() ) tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } - result(tradingDecision.toString(),false) - decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: "")) + result(tradingDecision,false) + result(decideTrading(stockCode, tradingDecision),true) } } + fun isUrlAlreadyIndexed(url: String): Boolean { + // 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성 + val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url) + + // 2. 검색 요청 생성 (벡터 유사도와 상관없이 필터 조건에 맞는 것 1개만 찾음) + // 주의: 인터페이스에 따라 더미 벡터(0,0,...)가 필요할 수 있습니다. + val searchRequest = EmbeddingSearchRequest.builder() + .filter(filter) + .maxResults(1) + .build() + + val result = embeddingStore.search(searchRequest) + + // 결과가 비어있지 않다면 이미 저장된 URL입니다. + return result.matches().isNotEmpty() + } /** * 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다. @@ -144,57 +226,80 @@ object RagService { suspend fun decideTrading( stockName: String, - techSummary: String, - newsContext: String, - financialData: String + tempDecision: TradingDecision ): TradingDecision? { val prompt = """ - <|begin_of_text|><|start_header_id|>system<|end_header_id|> - 당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다. - 제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오. - 아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요. - - [데이터 요약] - - 종목: $stockName - $techSummary - - 기업/재무: $financialData - - 시장 심리: $newsContext + <|begin_of_text|><|start_header_id|>system<|end_header_id|> + 당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다. + 제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오. + 아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요. + + [데이터 요약] + - 종목: $stockName + - 분석: ${tempDecision.techSummary} + - 기업/재무: ${tempDecision.financialData} + - 시장 심리: ${tempDecision.newsContext} - [스코어 산출 가이드 (0-100)] - 1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상. - 2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상. - 3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상. - 4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단. + [스코어 산출 가이드 (0-100)] + 1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상. + 2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상. + 3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상. + 4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단. - [응답 형식] - 반드시 아래 JSON 형식으로만 답변하십시오: - { - "ultraShortScore": (숫자), - "shortTermScore": (숫자), - "midTermScore": (숫자), - "longTermScore": (숫자), - "decision": "BUY" | "SELL" | "HOLD", - "reason": "결정적 근거 한 줄", - "confidence": 0~100 - } - <|eot_id|> - <|start_header_id|>user<|end_header_id|> - 모든 데이터를 종합하여 스코어링 리포트를 작성하십시오. - <|eot_id|><|start_header_id|>assistant<|end_header_id|> + [응답 지침 - 엄격 준수] + 1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오. + 2. 오직 JSON 데이터만 출력하십시오. + 3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다. + 4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다. + [응답 형식] + 반드시 아래 JSON 형식으로만 답변하십시오: + { + "ultraShortScore": (숫자), + "shortTermScore": (숫자), + "midTermScore": (숫자), + "longTermScore": (숫자), + "decision": "BUY" | "SELL" | "HOLD", + "reason": "결정적 근거 한 줄", + "confidence": 0~100 + } + <|eot_id|> + <|start_header_id|>user<|end_header_id|> + 모든 데이터를 종합하여 스코어링 리포트를 작성하십시오. + <|eot_id|><|start_header_id|>assistant<|end_header_id|> """.trimIndent() val response = chatModel.chat(UserMessage.from(prompt)) - val jsonResponse = response.aiMessage().text() + val rawResponse = response.aiMessage().text() + val jsonResponse = JsonSanitizer.formatJson(rawResponse) + println("📥 [AI Raw JSON]:\n$jsonResponse") + + // 2. 유연한 파서 설정 (소수점 및 예외 상황 대응) + val lenientJson = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + // JSON 파싱 (Kotlinx Serialization 활용) return try { println(jsonResponse) - val decision = Json.decodeFromString(jsonResponse) - decision.financialData = financialData - decision.newsContext = newsContext - decision.techSummary = techSummary + val decision = lenientJson.decodeFromString(jsonResponse) + decision.financialData = tempDecision.financialData + decision.newsContext = tempDecision.newsContext + decision.techSummary = tempDecision.techSummary + decision.stockCode = tempDecision.stockCode decision + } catch (e: dev.langchain4j.exception.InternalServerException) { + // 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리 + println("🚨 [AI Server Error] ${e.message}") + if (e.message?.contains("Context size") == true) { + println("⚠️ 데이터가 너무 많습니다. 요약 로직을 점검하세요.") + } + tempDecision + null } catch (e: Exception) { + println("❌ [General Error] ${e.message}") null } } @@ -203,18 +308,28 @@ object RagService { } @Serializable class TradingDecision { - val ultraShortScore: Int = 0 // 초단기 (분봉/에너지) - val shortTermScore: Int = 0 // 단기 (일봉/뉴스) - val midTermScore: Int = 0 // 중기 (주봉/재무) - val longTermScore: Int = 0 + + val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지) + val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스) + val midTermScore: Double = 0.0 // 중기 (주봉/재무) + val longTermScore: Double = 0.0 + var stockCode: String = "" var decision: String? = null var reason: String? = null - var confidence: Int = 0 + var confidence: Double = 0.0 var techSummary : String? = null var newsContext : String? = null var financialData : String? = null + + fun profitPossible() = + listOf(ultraShortScore, + shortTermScore, + midTermScore, + longTermScore).average() + override fun toString(): String { return """ +수익실현 가능성 : ${profitPossible()} ultraShortScore :$ultraShortScore shortTermScore :$shortTermScore midTermScore :$midTermScore @@ -222,9 +337,9 @@ longTermScore :$longTermScore decision: $decision reason: $reason confidence: $confidence -techSummary: $techSummary -newsContext: $newsContext -financialData: $financialData +기술 분석: $techSummary +뉴스: $newsContext +재무재표: $financialData """.trimIndent() } } \ No newline at end of file diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index d9c7034..8bd4b94 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -3,13 +3,8 @@ package service import TradingDecision import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData -import network.KisTradeService -import network.NewsService import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId @@ -18,31 +13,34 @@ import kotlin.collections.List import kotlin.math.* // service/AutoTradingManager.kt +typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit object AutoTradingManager { private val scope = CoroutineScope(Dispatchers.Default) - val targetStocks = mutableListOf() + val targetStocks = mutableListOf>() - fun addStock(stockCode : String, result :(String, Boolean)->Unit) { - targetStocks.add(stockCode) - startTradingLoop(result) + fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) { + targetStocks.add(Pair(stockName, stockCode)) + startTradingLoop(stockName,stockCode,result) } - fun startTradingLoop(result :(String, Boolean)->Unit) { + fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) { scope.launch { println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}") - targetStocks.forEach { stockCode -> +// targetStocks.forEach { stockCode -> launch { // 종목별 병렬 분석 (M3 Pro 파워 활용) - RagService.processStock(stockCode,result) {code ,decision -> - when (decision?.decision) { - "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수") - "SELL" -> executeOrder(stockCode, "매도") - else -> println("[$stockCode] 관망 유지: ${decision?.reason}") - } - result(decision.toString(),true) - } + RagService.processStock(stockName, stockCode,result) +// {decision,b -> +//// when (decision?.decision) { +//// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수") +//// "SELL" -> executeOrder(stockCode, "매도") +//// else -> println("[$stockCode] 관망 유지: ${decision?.reason}") +//// } +// result(decision,b) +// } } - } - delay(10 * 60 * 1000) // 10분 대기 +// } +// targetStocks.re +// delay(10 * 60 * 1000) // 10분 대기 } } @@ -112,7 +110,7 @@ object TechnicalAnalyzer { // [3] 이평선 및 가격 위치 val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average() val currentPrice = min30.last().stck_prpr.toDouble() - val signal = ScalpingAnalyzer().analyze(min30.toScalpingList()) + val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish()) // [4] 거래량 강도 val avgVol30 = min30.map { it.cntg_vol.toLong() }.average() val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average() @@ -121,30 +119,29 @@ object TechnicalAnalyzer { val stochK = calculateStochastic(min30) val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() } return """ - [초단기 기술적 스켈핑 분석] - - 종합 스코어: ${signal.compositeScore} / 100 - - 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"} - - 성공 확률 예측: ${signal.successProbPct}% - - 위험 등급: ${signal.riskLevel} (ATR 변동성 기반) - - RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배 - - 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원) +- 초/단타 종합 스코어: ${signal.compositeScore} / 100 +- 초/단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"} +- 초/단타 성공 확률 예측: ${signal.successProbPct}% +- 초/단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반) +- 초/단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배 +- 초/단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원) - 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"} - 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동 - 30분 대비: ${ "%.2f".format(change30) }% 변동 - 10분 대비: ${ "%.2f".format(change10) }% 변동 - 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"} -- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"}) -- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20) -- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치) +- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } +- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } +- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } - 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준 -- ATR (평균 변동폭): ${"%.0f".format(atr)}원 (최근 캔들 하나가 평균적으로 움직이는 크기) -- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 (최고가-최저가 차이) -- 스토캐스틱(%K): ${"%.1f".format(stochK)} (100에 가까울수록 최근 파동의 고점, 0에 가까울수록 저점) -- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준으로 전개 중 +- ATR (평균 변동폭): ${"%.0f".format(atr)}원 +- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 +- 스토캐스틱(%K): ${"%.1f".format(stochK)} +- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준 - 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }} - 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }} - RSI(14): ${ "%.1f".format(calculateRSI(min30)) } - """.trimIndent() +""".trimIndent() } /** @@ -194,6 +191,25 @@ object TechnicalAnalyzer { return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100 } + fun isDailyBullish(): Boolean { + if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리 + + val currentPrice = daily.last().stck_prpr.toDouble() + + // 1. MA20 (한 달 생명선) 계산 + val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() + + // 2. MA5 (단기 가속도) 계산 + val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average() + + // 3. 방향성 (어제 MA5 vs 오늘 MA5) + val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average() + val isMa5Rising = ma5 > prevMa5 + + // [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주 + return currentPrice > ma20 && isMa5Rising + } + fun calculateOBV(candles: List): Double { var obv = 0.0 for (i in 1 until candles.size) { @@ -298,7 +314,7 @@ class ScalpingAnalyzer { return Triple(upper, sma, lower) } - fun analyze(candles: List): ScalpingSignalModel { + fun analyze(candles: List, isDailyBullish: Boolean): ScalpingSignalModel { if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요") val closes = candles.map { it.close } @@ -323,16 +339,34 @@ class ScalpingAnalyzer { (currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last()) } else 0.5 - // 신호 조건 - val maBull = currentClose > sma10Now && sma10Now > sma20Now + + + val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high } + val isBreakout = currentClose > nearHigh + + // [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인) + val bodySize = abs(current.close - current.open) + val lowerShadow = minOf(current.close, current.open) - current.low + val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우 + + // 신호 조건 고도화 + // 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수 + +// val maBull = currentClose > sma10Now && sma10Now > sma20Now val rsiBull = rsiNow > RSI_THRESHOLD val volSurge = volRatioNow > VOL_SURGE_THRESHOLD val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS - val buySignal = maBull && rsiBull && volSurge && bbGood + val maBull = currentClose > sma10Now && sma10Now > sma20Now + val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout +// val buySignal = maBull && rsiBull && volSurge && bbGood - // 종합 스코어 (가중: MA 30%, RSI 20%, Vol 30%, BB 20%) - val score = (if (maBull) 30 else 0) + (if (rsiBull) 20 else 0) + - (minOf((volRatioNow - 1.0) * 30, 30.0)).toInt() + (if (bbGood) 20 else 0) + + val score = (if (maBull) 25 else 0) + + (if (rsiBull) 15 else 0) + + (if (isBreakout) 20 else 0) + // 돌파 에너지 가중치 + (minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() + + (if (bbGood) 10 else 0) + + (if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수 // 위험도 (ATR proxy) val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 } @@ -351,6 +385,8 @@ class ScalpingAnalyzer { val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100) val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT) + + return ScalpingSignalModel( currentPrice = currentClose, buySignal = buySignal, diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt new file mode 100644 index 0000000..40b688d --- /dev/null +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -0,0 +1,160 @@ +package service + +import com.microsoft.playwright.Playwright +import com.microsoft.playwright.BrowserType +import com.microsoft.playwright.Page +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import model.NewsItem +import network.CorpInfo +import kotlin.random.Random + +object DynamicNewsScraper { + private val playwright by lazy { Playwright.create() } + private val browser by lazy { + playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true)) + } + + fun extractSmartContentWithLineFilter(page: Page): String { + val script = """ + () => { + // 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제 + const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV']; + document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove()); + + const MIN_LINE_LENGTH = 10; + const MIN_TOTAL_LENGTH = 100; + const CONSECUTIVE_THRESHOLD = 2; + + // 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리) + const getRefinedText = (el) => { + // 실제 텍스트만 추출하여 라인별로 분리 + const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0); + let resultLines = []; + let tempBuffer = []; + let consecutiveShort = 0; + + lines.forEach(line => { + if (line.length <= MIN_LINE_LENGTH) { + consecutiveShort++; + tempBuffer.push(line); + } else { + // 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침 + if (consecutiveShort < CONSECUTIVE_THRESHOLD) { + resultLines = resultLines.concat(tempBuffer); + } + resultLines.push(line); + tempBuffer = []; + consecutiveShort = 0; + } + }); + + // 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비) + if (consecutiveShort < CONSECUTIVE_THRESHOLD) { + resultLines = resultLines.concat(tempBuffer); + } + return resultLines.join('\n'); + }; + + // 3. 후보 블록 탐색 및 텍스트 밀도 기반 분석 + const candidates = Array.from(document.querySelectorAll('div, section, article, p, main, td')) + .map(el => ({ + el: el, + refinedText: getRefinedText(el) + })) + .filter(item => { + if (item.refinedText.length < MIN_TOTAL_LENGTH) return false; + + // 링크 밀도 체크: 기사 본문은 보통 링크보다 텍스트 비중이 훨씬 높음 + const linkLength = Array.from(item.el.querySelectorAll('a')) + .reduce((acc, a) => acc + (a.innerText || "").length, 0); + return (linkLength / item.refinedText.length) < 0.3; + }); + + // 4. 가장 최적의(가장 깊은 계층의) 본문 컨테이너 선정 + const best = candidates.find(parent => + !candidates.some(child => + parent.el !== child.el && + parent.el.contains(child.el) && + child.refinedText.length > parent.refinedText.length * 0.8 + ) + ); + + return best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || ""); +} + """.trimIndent() + + return page.evaluate(script) as String + } + + suspend fun fetchFullContent(url: String): String { + val context = browser.newContext() + val page = context.newPage() + delay(Random.nextInt(1000).toLong()) + return try { + // 1. 페이지 이동 및 네트워크 유휴 상태까지 대기 + blockUnnecessaryResources(page) + page.navigate(url) +// println(url) + page.waitForLoadState() + + + var finded = cleanText(extractSmartContentWithLineFilter(page)) + println("finded : $finded") + finded + } catch (e: Exception) { + println("❌ [Playwright] 스크래핑 실패: ${e.message}") + "" + } finally { + page.close() + context.close() + } + } + + private fun blockUnnecessaryResources(page: Page) { + // 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단 + page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route -> + route.abort() + } + } + + private fun cleanText(text: String): String { + return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거 + .replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거 + .trim() + } +} + +object SafeScraper { + // 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다) + private val semaphore = Semaphore(5) + + suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List) = coroutineScope { + var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}" + urls.map { item -> + async { + if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) { + semaphore.withPermit { + RagService.ingestWithChunking( + text = DynamicNewsScraper.fetchFullContent(item.originallink), + newsLink = item.originallink, + pubDate = item.pubDate, + stockCode = corpInfo.stockCode, + corpName = corpInfo.cName, + corpCode = corpInfo.cCode, + stcokName = corpInfo.stockName + ) + } + println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링") + } else { + println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵") + } + } + }.awaitAll() + println("$query 관련 뉴스 ${urls.size}개 학습 완료") + } +} \ No newline at end of file diff --git a/src/main/kotlin/network/LlamaServerManager.kt b/src/main/kotlin/service/LlamaServerManager.kt similarity index 82% rename from src/main/kotlin/network/LlamaServerManager.kt rename to src/main/kotlin/service/LlamaServerManager.kt index de478d7..0c4e74b 100644 --- a/src/main/kotlin/network/LlamaServerManager.kt +++ b/src/main/kotlin/service/LlamaServerManager.kt @@ -1,9 +1,12 @@ -package network +package service -import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import java.io.BufferedReader +import java.io.File import java.io.InputStreamReader -import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap object LlamaServerManager { @@ -24,7 +27,7 @@ object LlamaServerManager { binPath, "-m", modelPath, "--port", port.toString(), - "-c", if (port == 8081) "512" else "4096", // 임베딩용은 컨텍스트가 짧아도 충분합니다. + "-c", if (port == 8081) "512" else "8192", // 임베딩용은 컨텍스트가 짧아도 충분합니다. "-ngl", nGpuLayers.toString(), "-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장 "--embedding" // 임베딩 기능을 활성화합니다. @@ -45,15 +48,20 @@ object LlamaServerManager { var line: String? while (reader.readLine().also { line = it } != null) { // 로그 출력 (디버깅용) - println("[Server $port] $line") +// println("[Server $port] $line") if (line?.contains("server is listening") == true) { println("🚀 AI 서버 준비 완료 (Port: $port)") + if (processes.size > 1) { + println("[Cache] ${processes.size}") + RagService.active() + } } } } catch (e: Exception) { println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}") processes.remove(port) } + } } diff --git a/src/main/kotlin/service/UrlCacheManager.kt b/src/main/kotlin/service/UrlCacheManager.kt new file mode 100644 index 0000000..b1fb006 --- /dev/null +++ b/src/main/kotlin/service/UrlCacheManager.kt @@ -0,0 +1,55 @@ +package service + +import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore +import dev.langchain4j.model.embedding.EmbeddingModel +import dev.langchain4j.store.embedding.EmbeddingSearchRequest +import java.util.concurrent.ConcurrentHashMap + +object UrlCacheManager { + // 1. Thread-safe한 메모리 캐시 (M3 Pro의 멀티코어 환경 대응) + private val processedUrls = ConcurrentHashMap.newKeySet() + + /** + * Lucene 저장소에서 모든 link 메타데이터를 읽어와 캐시를 초기화합니다. + */ + fun initialize(embeddingStore: LuceneEmbeddingStore, embeddingModel: EmbeddingModel) { + try { + // 1. 더미 텍스트로 기준 벡터 생성 (또는 0으로 채워진 리스트 생성) + // 모델에게 아무 단어나 던져서 기준이 될 벡터 하나를 임시로 만듭니다. + val dummyEmbedding = embeddingModel.embed("initial_load").content() + + // 2. 검색 요청에 더미 벡터 포함 + val searchRequest = EmbeddingSearchRequest.builder() + .queryEmbedding(dummyEmbedding) // [필수] 기준 벡터 주입으로 에러 해결 + .maxResults(2000) + .build() + + val result = embeddingStore.search(searchRequest) + + result.matches().forEach { match -> + val url = match.embedded().metadata().getString("link") as? String + if (url != null) { + processedUrls.add(url) + } + } + println("✅ [Cache] Lucene으로부터 ${processedUrls.size}개의 URL 로드 완료") + } catch (e: Exception) { + println("⚠️ [Cache] 초기화 실패: ${e.message}") + } + } + + fun isInitialized(): Boolean { + return processedUrls.isNotEmpty() + } + /** + * 중복 여부 확인 (O(1) 속도) + */ + fun isAlreadyProcessed(url: String): Boolean = processedUrls.contains(url) + + /** + * 캐시 업데이트 + */ + fun addToCache(url: String) { + processedUrls.add(url) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index ba65ac8..a96dbeb 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -26,9 +26,10 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import model.KisSession import service.AutoTradingManager +import service.TradingDecisionCallback @Composable -fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List) { +fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List, tradingDecisionCallback: TradingDecisionCallback) { var aiOpinion by remember { mutableStateOf("분석 대기 중...") } var code by remember(stockCode) { aiOpinion = "" @@ -66,18 +67,10 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra scope.launch { isAnalyzing = true try { - AutoTradingManager.addStock(stockCode) { msg,success -> - aiOpinion = msg + AutoTradingManager.addStock(stockName,stockCode) { decision,success -> + aiOpinion = decision.toString() isAnalyzing = !success } - // 실시간 데이터 수집부터 분석까지 한 번에 실행 -// StockAnalysisManager.analyzeStockWithMultiData( -// stockCode = stockCode, -// stockName = stockName, -// result = { -// aiOpinion = it -// } -// ) } catch (e: Exception) { aiOpinion = "분석 중 오류 발생: ${e.message}" println(aiOpinion) diff --git a/src/main/kotlin/ui/BalanceSection.kt b/src/main/kotlin/ui/BalanceSection.kt index a7471d5..686177b 100644 --- a/src/main/kotlin/ui/BalanceSection.kt +++ b/src/main/kotlin/ui/BalanceSection.kt @@ -152,10 +152,6 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) } Spacer(Modifier.width(4.dp)) Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) - Text( - "매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}", - fontSize = 11.sp, color = Color.Gray - ) } Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) } @@ -174,6 +170,10 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) fontSize = 12.sp, fontWeight = FontWeight.Bold ) + Text( + "매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}", + fontSize = 11.sp, color = Color.Gray + ) } } } diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index e7c41ce..f00ea8e 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -2,6 +2,7 @@ package ui import AutoTradeItem +import TradingDecision import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -16,7 +17,6 @@ import model.KisSession import model.StockBasicInfo import network.KisTradeService import network.KisWebSocketManager -import util.MarketUtil @Composable fun DashboardScreen() { @@ -30,6 +30,8 @@ fun DashboardScreen() { var selectedItem by remember { mutableStateOf(null) } // 감시/미체결 아이템 선택 시 var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 + var completeTradingDecision by remember { mutableStateOf(null) } // 단순 종목 선택 시 + // 중앙 관리용 상태들 var refreshTrigger by remember { mutableStateOf(0) } @@ -141,7 +143,8 @@ fun DashboardScreen() { scope.launch { syncAndExecute(orderNo) // 매칭 시도 } - } + }, + completeTradingDecision ) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -156,7 +159,12 @@ fun DashboardScreen() { stockCode = selectedStockCode, stockName = selectedStockName, currentPrice = wsManager.currentPrice.value, - trades = wsManager.tradeLogs + trades = wsManager.tradeLogs, + tradingDecisionCallback = { decision,bool -> + if (bool && decision != null && KisSession.config.isSimulation) { + completeTradingDecision = decision + } + } ) } VerticalDivider() @@ -196,8 +204,12 @@ fun DashboardScreen() { } } } + + } + + @Composable fun VerticalDivider() { Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 90233ea..13efe18 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -2,7 +2,7 @@ package ui import AutoTradeItem -import androidx.compose.foundation.background +import TradingDecision import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -21,7 +21,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import network.KisTradeService -import network.KisWebSocketManager import util.MarketUtil /** @@ -40,7 +39,8 @@ fun IntegratedOrderSection( holdingQuantity: String, tradeService: KisTradeService, onOrderSaved: (String) -> Unit, - onOrderResult: (String, Boolean) -> Unit + onOrderResult: (String, Boolean) -> Unit, + completeTradingDecision: TradingDecision? ) { val scope = rememberCoroutineScope() @@ -60,6 +60,7 @@ fun IntegratedOrderSection( } + // UI 입력 상태 var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 var orderQty by remember(holdingQuantity) { @@ -80,6 +81,48 @@ fun IntegratedOrderSection( val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0) val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 + fun excuteTrade(willEnableAutoSell: Boolean,orderQty: String) { + scope.launch { + val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice + tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) + .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 + onOrderResult("주문 성공: $realOrderNo", true) + if (willEnableAutoSell) { + val pRate = profitRate.toDoubleOrNull() ?: 0.0 + val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 + val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0)) + val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) + + DatabaseFactory.saveAutoTrade(AutoTradeItem( + orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙) + code = stockCode, + name = stockName, + quantity = inputQty, + profitRate = pRate, + stopLossRate = sRate, + targetPrice = calculatedTarget, + stopLossPrice = calculatedStop, + status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태 + isDomestic = isDomestic + )) + monitoringItem = DatabaseFactory.findConfigByCode(stockCode) + onOrderSaved(realOrderNo) + onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true) + } + } + .onFailure { onOrderResult(it.message ?: "매수 실패", false) } + } + } + LaunchedEffect(completeTradingDecision) { + if (completeTradingDecision != null && + completeTradingDecision.stockCode.equals(stockCode)) { + when (completeTradingDecision?.decision) { + "BUY" -> if (completeTradingDecision.confidence > 70) excuteTrade(true, "1") + "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") + else -> println("[$stockCode] 관망 유지: ${completeTradingDecision?.reason}") + } + } + } Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) @@ -171,36 +214,7 @@ fun IntegratedOrderSection( // 매수 버튼 Button( onClick = { - scope.launch { - val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice - tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) - .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 - onOrderResult("주문 성공: $realOrderNo", true) - if (willEnableAutoSell) { - val pRate = profitRate.toDoubleOrNull() ?: 0.0 - val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 - val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0)) - val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) - - DatabaseFactory.saveAutoTrade(AutoTradeItem( - orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙) - code = stockCode, - name = stockName, - quantity = inputQty, - profitRate = pRate, - stopLossRate = sRate, - targetPrice = calculatedTarget, - stopLossPrice = calculatedStop, - status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태 - isDomestic = isDomestic - )) - monitoringItem = DatabaseFactory.findConfigByCode(stockCode) - onOrderSaved(realOrderNo) - onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true) - } - } - .onFailure { onOrderResult(it.message ?: "매수 실패", false) } - } + excuteTrade(willEnableAutoSell,orderQty) }, modifier = Modifier.weight(1f).padding(end = 4.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) @@ -224,6 +238,8 @@ fun IntegratedOrderSection( ) { Text("매도", color = Color.White) } } } + + } @Composable diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 3834fa6..c2b3904 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -2,15 +2,10 @@ package ui -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import TradingDecision import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* -import io.ktor.client.engine.cio.CIO // 아래 두 import가 'delegate' 에러를 해결합니다. import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue @@ -18,20 +13,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import model.AppConfig -import model.BalanceSummary import model.CandleData -import model.RankingStock -import model.StockHolding +import network.DartCodeManager import network.KisTradeService import network.KisWebSocketManager +import network.NewsService import service.TechnicalAnalyzer import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -41,11 +31,12 @@ import kotlin.collections.isNotEmpty fun StockDetailSection( stockCode: String, stockName: String, - holdingQuantity : String, + holdingQuantity: String, isDomestic: Boolean, tradeService: KisTradeService, wsManager: KisWebSocketManager, - onOrderSaved: (String) -> Unit + onOrderSaved: (String) -> Unit, + completeTradingDecision: TradingDecision? ) { var openPrice by remember { mutableStateOf("0") } @@ -65,11 +56,7 @@ fun StockDetailSection( if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0" } - fun calculateAvg(data: List): String { - if (data.isEmpty()) return "0" - val avg = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average() - return String.format("%,d", avg.toLong()) - } + // 이전 종목 코드를 기억하기 위한 상태 var previousCode by remember { mutableStateOf("") } @@ -88,6 +75,7 @@ fun StockDetailSection( wsManager.subscribeStock(stockCode) previousCode = stockCode + // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) coroutineScope { @@ -101,18 +89,33 @@ fun StockDetailSection( .onFailure { error -> println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") chartData = emptyList() - }} + } + } launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { - daySummary = it.takeLast(7) } - TechnicalAnalyzer.daily = daySummary + daySummary = it.takeLast(7) + TechnicalAnalyzer.daily = it + println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") + } } // 최근 7일 - launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } - TechnicalAnalyzer.weekly = weekSummary} // 최근 4주 + launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { + weekSummary = it.takeLast(4) + TechnicalAnalyzer.weekly = it + println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") + } + } // 최근 4주 launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { monthSummary = it.takeLast(6) // 최근 6개월 yearSummary = it.takeLast(36) // 최근 3년 - TechnicalAnalyzer.monthly = yearSummary - }} + TechnicalAnalyzer.monthly = it + println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") + } + } + launch { + DartCodeManager.getCorpCode(stockCode)?.let { + it.stockName = stockName + NewsService.fetchAndIngestNews(it) + } + } } isLoading = false } @@ -230,7 +233,8 @@ fun StockDetailSection( onOrderResult = { msg, success -> resultMessage = msg isSuccess = success - } + }, + completeTradingDecision ) } }