diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index a2f1757..ff7d13d 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -5,7 +5,9 @@ import java.time.LocalDateTime const val feesAndTaxRate = 0.33 const val minimumNetProfit = 0.35 const val buyWeight = 2.0 - +val MAX_BUDGET = 40000.0 +val MAX_PRICE = 20000 +val MIN_PRICE = 1500 data class AppConfig( // [DB 저장 데이터] // 실전 3종 diff --git a/src/main/kotlin/network/AiService.kt b/src/main/kotlin/network/AiService.kt deleted file mode 100644 index 19572ef..0000000 --- a/src/main/kotlin/network/AiService.kt +++ /dev/null @@ -1,125 +0,0 @@ -package network - -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import model.RealTimeTrade - -object AiService { - private val client = HttpClient(CIO) { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - coerceInputValues = true - }) - } - install(HttpTimeout) { - requestTimeoutMillis = 60_000 // 전체 요청 대기 시간을 60초로 설정 - connectTimeoutMillis = 10_000 // 서버 연결 대기 시간 10초 - socketTimeoutMillis = 60_000 // 데이터 수신 대기 시간 60초 - } - } - -// private const val LLM_URL = "http://localhost:8080/completion" - private const val LLM_URL = "http://127.0.0.1:8080/completion" - /** - * 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다. - */ - suspend fun fetchAnalysis( - stockName: String, - currentPrice: String, - trades: List - ): String { - // 최근 체결 내역 10개를 텍스트로 요약 - val tradeSummary = trades.take(10).joinToString("\n") { trade -> - "- ${trade.time}: ${trade.price}원 (${trade.volume}주 ${if (trade.type.name == "BUY") "매수" else "매도"})" - } - - // Gemma에게 전달할 프롬프트 구성 - val prompt = """ - <|begin_of_text|><|start_header_id|>system<|end_header_id|> - 당신은 20년 경력의 주식 트레이더입니다. 데이터를 분석하여 짧고 단호하게 조언합니다.<|eot_id|><|start_header_id|>user<|end_header_id|> - 다음 데이터를 분석하여 '수급 상황'과 '단기 전망'을 3줄 이내로 요약하세요. - - [종목] $stockName ($currentPrice) - [최근 체결] - $tradeSummary - <|eot_id|><|start_header_id|>assistant<|end_header_id|> - """.trimIndent() - - return try { - val response = client.post(LLM_URL) { - contentType(ContentType.Application.Json) - setBody(LlamaRequest(prompt = prompt)) - } - - if (response.status == HttpStatusCode.OK) { - val result: LlamaResponse = response.body() - result.content.trim() - } else { - "AI 서버 응답 오류: ${response.status}" - } - } catch (e: Exception) { - var msg = "분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})" - println(msg) - msg - } - } - - - suspend fun getEmbedding(text: String): List? { - return try { - val response = client.post("http://127.0.0.1:8080/embedding") { - contentType(ContentType.Application.Json) - setBody(EmbeddingRequest(content = text)) - } - if (response.status == HttpStatusCode.OK) { - val res: EmbeddingResponse = response.body() - res.embedding - } else null - } catch (e: Exception) { - null - } - } - - -} - - -@Serializable -data class EmbeddingRequest(val content: String) - -@Serializable -data class EmbeddingResponse(val embedding: List) - -/** - * llama.cpp 서버 요청 데이터 구조 - */ -@Serializable -data class LlamaRequest( - val prompt: String, - val n_predict: Int = 256, // 답변 길이를 엄격히 제한 - val temperature: Double = 0.4, // M3 Pro에서 더 일관된 답변을 위해 낮춤 - val stop: List = listOf( - "<|eot_id|>", - "<|end_of_text|>", - "<|start_header_id|>", - "user", - "model" - ) // [중요] AI가 멈춰야 할 지점들을 명확히 지정 -) - -/** - * llama.cpp 서버 응답 데이터 구조 - */ -@Serializable -data class LlamaResponse( - val content: String -) \ No newline at end of file diff --git a/src/main/kotlin/network/DartCodeManager.kt b/src/main/kotlin/network/DartCodeManager.kt index 8314f77..81cf488 100644 --- a/src/main/kotlin/network/DartCodeManager.kt +++ b/src/main/kotlin/network/DartCodeManager.kt @@ -40,14 +40,14 @@ object DartCodeManager { val url = "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$DART_API_KEY" val response: HttpResponse = client.get(url) val zipBytes = response.readBytes() - val zipFile = File("dart_corp_codes.zip") - zipFile.writeBytes(zipBytes) - println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)") +// val zipFile = File("dart_corp_codes.zip") +// zipFile.writeBytes(zipBytes) +// println("💾 [디버그] 원본 ZIP 저장 완료: ${zipFile.absolutePath} (${zipBytes.size} bytes)") ZipInputStream(ByteArrayInputStream(zipBytes)).use { zis -> var entry = zis.nextEntry while (entry != null) { if (entry.name == "CORPCODE.xml") { - saveXmlDebugFile(zipBytes) +// saveXmlDebugFile(zipBytes) parseXml(zis.readAllBytes()) break } diff --git a/src/main/kotlin/network/KisAuthService.kt b/src/main/kotlin/network/KisAuthService.kt index 3baa647..c2b016d 100644 --- a/src/main/kotlin/network/KisAuthService.kt +++ b/src/main/kotlin/network/KisAuthService.kt @@ -43,7 +43,7 @@ class KisAuthService { */ suspend fun refreshAllTokens(): Boolean = coroutineScope { val config = KisSession.config - + println("refreshAllTokens") // 1. 실전 시세용 토큰 발급 (Market Token) val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) } @@ -75,6 +75,7 @@ class KisAuthService { private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result { return try { + println("fetchAccessToken") val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") { contentType(ContentType.Application.Json) setBody(TokenRequest("client_credentials", appKey, secretKey)) @@ -82,6 +83,7 @@ class KisAuthService { if (response.status == HttpStatusCode.OK) Result.success(response.body()) else Result.failure(Exception("인증 실패: ${response.status}")) } catch (e: Exception) { + println("fetchAccessToken ${e.message}") Result.failure(e) } } diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 13f85c2..920ff2a 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -1,6 +1,5 @@ package network -import AutoTradeItem import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.CIO @@ -11,7 +10,6 @@ import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.request.* import io.ktor.client.statement.bodyAsText -import io.ktor.client.statement.request import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json @@ -55,11 +53,9 @@ object KisTradeService { */ suspend fun fetchIntegratedBalance(): Result = coroutineScope { val config = KisSession.config - // 국내와 해외 잔고를 비동기로 동시 호출 val domesticJob = async { fetchDomesticRawBalance() } val overseasJob = async { fetchOverseasRawBalance() } - try { val domRes = domesticJob.await().getOrNull() val ovsRes = overseasJob.await().getOrNull() @@ -87,14 +83,15 @@ object KisTradeService { val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) + (ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L - + println("fetchIntegratedBalance O") Result.success(UnifiedBalance( totalAsset = String.format("%,d", totalAmt), totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0", deposit = String.format("%,d", depositAmt), holdings = combinedHoldings )) - } catch (e: Exception) { Result.failure(e) } + } catch (e: Exception) { + Result.failure(e) } } /** @@ -505,8 +502,8 @@ object KisTradeService { // --- 내부 Raw 호출용 (통합 잔고에서 사용) --- private suspend fun fetchDomesticRawBalance(): Result { val config = KisSession.config - val baseUrl = if (config.isSimulation) vtsUrl else prodUrl - val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R" + val baseUrl = prodUrl + val trId = "TTTC8434R" var pureAccount = config.accountNo.replace("-", "").trim() if (pureAccount.length == 8) pureAccount += "01" @@ -522,7 +519,7 @@ object KisTradeService { parameter("ACNT_PRDT_CD", acntPrdtCd) parameter("AFHR_FLPR_YN", "N") parameter("OFL_YN", "N") - parameter("INQR_DVSN", "02") + parameter("INQR_DVSN", "0") parameter("UNPR_DVSN", "01") parameter("FUND_STTL_ICLD_YN", "N") parameter("FNCG_AMT_AUTO_RDPT_YN", "N") @@ -530,7 +527,8 @@ object KisTradeService { parameter("CTX_AREA_FK100", "") parameter("CTX_AREA_NK100", "") } - Result.success(response.body()) + val body = response.body() + Result.success(body) } catch (e: Exception) { Result.failure(e) } } diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index 533e2a0..b228317 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -15,12 +15,14 @@ import io.ktor.client.request.parameter import io.ktor.http.ContentType.Application.Json import io.ktor.http.Url import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import model.DartFinancialResponse import model.NaverNewsResponse import service.DynamicNewsScraper import service.SafeScraper import service.UrlCacheManager +import kotlin.Double object NewsService { private val client = HttpClient(CIO) { @@ -41,25 +43,25 @@ object NewsService { "${corpInfo.stockName} 주가", "${corpInfo.stockName} 실적", "${corpInfo.stockName} 공시", - "${corpInfo.stockName} 이벤트" +// "${corpInfo.stockName} 이벤트" ) val qlistCorpTrend = listOf( "${corpInfo.cName} 최근 동향", "${corpInfo.cName} 이슈", - "${corpInfo.cName} 투자", - "${corpInfo.cName} 실적" +// "${corpInfo.cName} 투자", +// "${corpInfo.cName} 실적" ) (qlistNews + qlistCorpTrend).forEach { query -> try { val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { parameter("query", query) - parameter("display", 5) // 최근 10개 뉴스 + parameter("display", 4) // 최근 10개 뉴스 parameter("sort", "date") // 유사도 순 (또는 date 발간순) header("X-Naver-Client-Id", clientId) header("X-Naver-Client-Secret", clientSecret) }.body() - SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(5) ) + SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(2) ) } catch (e: Exception) { println("❌ 뉴스 가져오기 실패: ${e.message}") } @@ -89,7 +91,7 @@ object NewsService { val response = client.get(url).body() val accounts = response.list ?: return "재무 데이터 없음" var buffer : StringBuffer = StringBuffer() - buffer.append("[재무 분석 데이터]") + buffer.append("[재무 분석 데이터]").append("\n") response.list.forEach { it buffer.append("${it.account_nm} (당기)${it?.thstrm_amount}, (전기)${it?.frmtrm_amount}").append("\n") } @@ -101,6 +103,75 @@ object NewsService { return "" } } +} + +object FinancialMapper { + /** + * 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환 + */ + fun mapRawTextToStatement(rawText: String): FinancialStatement { + if (rawText.isBlank()) { + return FinancialStatement() + } + val currentValues = extractYearlyValues(rawText, "당기") + val previousValues = extractYearlyValues(rawText, "전기") + + // 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100 + val opCurrent = currentValues["영업이익"] ?: 0.0 + val opPrevious = previousValues["영업이익"] ?: 0.0 + val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0 + + // 2. 당기순이익 증가율 + val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0 + val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0 + val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0 + + // 3. ROE: 당기순이익 / 당기 자본총계 * 100 + val equityCurrent = currentValues["자본총계"] ?: 1.0 + val roe = (niCurrent / equityCurrent) * 100 + + // 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100 + val debtCurrent = currentValues["부채총계"] ?: 0.0 + val debtRatio = (debtCurrent / equityCurrent) * 100 + + // 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100 + val currentAssets = currentValues["유동자산"] ?: 0.0 + val currentLiabilities = currentValues["유동부채"] ?: 1.0 + val quickRatio = (currentAssets / currentLiabilities) * 100 + + return FinancialStatement( + operatingProfitGrowth = opGrowth, + netIncomeGrowth = niGrowth, + roe = roe, + debtRatio = debtRatio, + quickRatio = quickRatio, + isOperatingProfitPositive = opCurrent > 0, + isNetIncomePositive = niCurrent > 0 + ) + } + + private fun extractYearlyValues(text: String, type: String): Map { + val result = mutableMapOf() + // 정규식 설명: 항목명 뒤의 (당기/전기) 괄호 안의 숫자와 콤마를 찾아 숫자로 변환 + val regex = Regex("""([가-힣\s()]+)\s\(?$type\)?([-0-9,.]+)""") + regex.findAll(text).forEach { match -> + val key = match.groupValues[1].trim() + val value = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0 + result[key] = value + } + return result + } +} -} \ No newline at end of file +@Serializable +data class FinancialStatement( + val revenueGrowth: Double = 0.0, // 매출액 증가율 + val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율 + val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율 + val roe: Double = 0.0, // ROE + val debtRatio: Double = 0.0, // 부채비율 + val quickRatio: Double = 0.0, // 당좌비율 + val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부 + val isNetIncomePositive: Boolean = false +) \ No newline at end of file diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 510c173..309e8b1 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -14,8 +14,12 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import model.CandleData import network.DartCodeManager +import network.FinancialMapper +import network.FinancialStatement import network.NewsService import org.apache.lucene.store.MMapDirectory +import service.FinancialAnalyzer +import service.InvestmentScores import service.TechnicalAnalyzer import service.TradingDecisionCallback import service.UrlCacheManager @@ -131,39 +135,46 @@ object RagService { // 1. 10분간의 데이터 가져오기 (API 호출) coroutineScope { try { - - var tradingDecision: TradingDecision = TradingDecision() tradingDecision.stockCode = stockCode var corpInfo = DartCodeManager.getCorpCode(stockCode) corpInfo?.stockName = stockName tradingDecision.stockName = stockName tradingDecision.corpName = corpInfo?.cName ?: "" - corpInfo?.let { - try { - NewsService.fetchAndIngestNews(it) - } catch (e: Exception) {} - } - val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } tradingDecision.financialData = financialDataDeferred.await() - result(tradingDecision, false) - tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() - result(tradingDecision, false) + val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "") + if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) { + corpInfo?.let { + try { + NewsService.fetchAndIngestNews(it) + } catch (e: Exception) {} + } - val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" - val questionEmbedding = embeddingModel.embed(question).content() - val searchResult = embeddingStore.search( - EmbeddingSearchRequest.builder() - .queryEmbedding(questionEmbedding) - .maxResults(3) - .build() - ) - tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } - result(tradingDecision, false) - result(decideTrading(stockCode, tradingDecision), true) + val financialScore = FinancialAnalyzer.calculateScore(financialStmt) + val scores = technicalAnalyzer.calculateScores(financialScore) + + result(tradingDecision, false) + + tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() + result(tradingDecision, false) + + val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" + val questionEmbedding = embeddingModel.embed(question).content() + val searchResult = embeddingStore.search( + EmbeddingSearchRequest.builder() + .queryEmbedding(questionEmbedding) + .maxResults(3) + .build() + ) + tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } + result(tradingDecision, false) + result(decideTrading(stockCode, scores,financialStmt,tradingDecision), true) + } else { + result(tradingDecision, false) + } }catch (e: Exception) { e.printStackTrace() } @@ -237,45 +248,55 @@ object RagService { suspend fun decideTrading( stockName: String, + scores: InvestmentScores, // 직접 계산한 점수 객체 + financialStmt: FinancialStatement, // 매핑된 재무 수치 객체 tempDecision: TradingDecision ): TradingDecision? { val prompt = """ <|begin_of_text|><|start_header_id|>system<|end_header_id|> - 당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다. - 제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오. - 아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요. - - [데이터 요약] - - 종목: $stockName - - 분석: ${tempDecision.techSummary} - - 기업/재무: ${tempDecision.financialData} - - 시장 심리: ${tempDecision.newsContext} + 당신은 정량적 수치와 정성적 뉴스를 통합 분석하는 'AI 수석 애널리스트'입니다. + 시스템이 계산한 지표 점수와 실제 재무제표 요약본을 바탕으로 최종 매매 전략을 수립하십시오. - [스코어 산출 가이드 (0-100)] - 1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상. - 2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상. - 3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상. - 4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단. + [종목 정보] + - 종목명: $stockName + + [1. 시스템 산출 스코어 (0-100)] + - 초단기(Scalping): ${scores.ultraShort} + - 단기(Daily): ${scores.shortTerm} + - 중기(Weekly): ${scores.midTerm} + - 장기(Monthly): ${scores.longTerm} + + [2. 핵심 재무제표 요약] + - 영업이익: ${if(financialStmt.isOperatingProfitPositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%) + - 당기순이익: ${if(financialStmt.isNetIncomePositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.netIncomeGrowth)}%) + - 수익성(ROE): ${"%.2f".format(financialStmt.roe)}% + - 안정성(부채비율): ${"%.2f".format(financialStmt.debtRatio)}% + - 유동성(당좌비율): ${"%.2f".format(financialStmt.quickRatio)}% + + [3. 시장 심리 및 뉴스 컨텍스트] + ${tempDecision.newsContext} + + [분석 지침] + 1. **재무-뉴스 정합성**: 재무제표상 영업이익이 적자임에도 뉴스가 장기적 장밋빛 전망만 내놓는다면 '신중(HOLD)' 의견을 제시하십시오. + 2. **기술-심리 동기화**: 초단기 점수가 높고 뉴스에서 수급 급증 키워드가 포착되면 'BUY' 신뢰도를 높이십시오. + 3. **종합 결정**: 모든 수치와 컨텍스트를 고려하여 최종 Decision을 내리고, 그 근거를 핵심만 기술하십시오. + + [응답 지침] + - JSON 데이터만 출력하십시오. 설명이나 서론은 생략합니다. + - 반드시 아래 형식을 엄격히 준수하십시오. - [응답 지침 - 엄격 준수] - 1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오. - 2. 오직 JSON 데이터만 출력하십시오. - 3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다. - 4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다. - [응답 형식] - 반드시 아래 JSON 형식으로만 답변하십시오: { - "ultraShortScore": (숫자), - "shortTermScore": (숫자), - "midTermScore": (숫자), - "longTermScore": (숫자), + "ultraShortScore": ${scores.ultraShort}, + "shortTermScore": ${scores.shortTerm}, + "midTermScore": ${scores.midTerm}, + "longTermScore": ${scores.longTerm}, "decision": "BUY" | "SELL" | "HOLD", - "reason": "결정적 근거 한 줄", + "reason": "재무 수치와 뉴스 심리를 대조한 최종 결론 한 줄", "confidence": 0~100 } <|eot_id|> <|start_header_id|>user<|end_header_id|> - 모든 데이터를 종합하여 스코어링 리포트를 작성하십시오. + 상기 데이터를 통합 분석하여 최종 리포트를 생성하십시오. <|eot_id|><|start_header_id|>assistant<|end_header_id|> """.trimIndent() diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 5805f14..5078f0c 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -14,9 +14,13 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import model.CandleData +import model.MAX_PRICE +import model.MIN_PRICE import model.RankingStock import model.RankingType import network.DartCodeManager +import network.FinancialMapper +import network.FinancialStatement import network.KisTradeService import java.time.LocalDateTime import java.time.LocalTime @@ -39,11 +43,13 @@ object AutoTradingManager { // 설정 상수 private const val MIN_RISE_RATE = 0.1 private const val MAX_RISE_RATE = 15.0 - private const val CYCLE_TIMEOUT = 10 * 60 * 1000L // 한 사이클 최대 10분 + private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 fun isRunning(): Boolean = discoveryJob?.isActive == true + private var remainingCandidates = mutableListOf() +// private val processedCodes = mutableSetOf() // 중복 처리 방지용 (선택 사항) /** * 자동 발굴 루프 시작 및 Watchdog 실행 @@ -82,36 +88,47 @@ object AutoTradingManager { // [프로세스 1] 장 마감 및 잔고 체크 val now = LocalTime.now(ZoneId.of("Asia/Seoul")) //&& now.isBefore(LocalTime.of(15, 30)) - if (now.isAfter(LocalTime.of(15, 30)) ) { - executeClosingLiquidation(tradeService) - return@withTimeout - } +// if (now.isAfter(LocalTime.of(15, 30)) ) { +// executeClosingLiquidation(tradeService) +// return@withTimeout +// } val balance = tradeService.fetchIntegratedBalance().getOrNull() val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } // [프로세스 2] 후보군 수집 - val candidates = fetchCandidates(tradeService).apply { - println("후보군 총 개수 : $size") + if (remainingCandidates.isEmpty()) { + val candidates = fetchCandidates(tradeService).apply { + println("후보군 총 개수 : $size") + } + .filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE } + .filter { it.code !in myHoldings && it.code !in pendingStocks } + .distinctBy { it.code } + .sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) } + .apply { + println("후보군 조건 충족 총 개수 : $size") + } + remainingCandidates.addAll(candidates) + } else { + println("미확인 데이터 ${remainingCandidates.size}") } - .filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE } - .filter { it.code !in myHoldings && it.code !in pendingStocks } - .distinctBy { it.code } - .apply { - println("후보군 조건 충족 총 개수 : $size") - } - // [프로세스 3] 종목별 순회 분석 - candidates.forEach { stock -> - try { - lastTickTime.set(System.currentTimeMillis()) // 종목별로도 생존 신고 - processSingleStock(stock, myCash, tradeService, callback) - } catch (e: Exception) { + val iterator = remainingCandidates.iterator() + while (iterator.hasNext()) { + val stock = iterator.next() - }finally { - delay(300) + try { + processSingleStock(stock, myCash, tradeService, callback) + // 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거 + } catch (e: Exception) { + println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}") + // 오류 시 리스트에 남겨둘지, 제거할지 결정 + // (심각한 에러면 remove하고 다음 루프에서 다시 받는게 안전) + } finally { + iterator.remove() } + delay(300) } println("⏱️ [Cycle End] ${LocalTime.now()}") @@ -142,7 +159,7 @@ object AutoTradingManager { val today = dailyData.lastOrNull() ?: return@withTimeout val currentPrice = today.stck_prpr.toDouble() - if (currentPrice > myCash || currentPrice > 15000 || currentPrice < 900) return@withTimeout + if (currentPrice > myCash || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) return@withTimeout println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})") callback(TradingDecision().apply { @@ -163,6 +180,7 @@ object AutoTradingManager { } } + RagService.processStock(analyzer, stock.name, stock.code) { decision, isSuccess -> callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) } @@ -186,7 +204,7 @@ object AutoTradingManager { // async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, - async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) }, +// async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) }, @@ -252,12 +270,89 @@ object AutoTradingManager { } } + +object FinancialAnalyzer { + + fun isSafetyBeltMet(fs: FinancialStatement): Boolean { + val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만 + val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상 + val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함 + + return isDebtSafe && isLiquiditySafe && isNotDeficit + } + + /** + * [매수 고려] 우량 기업 요건 확인 + * 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다. + */ + fun isBuyConsiderationMet(fs: FinancialStatement): Boolean { + val highProfitability = fs.roe >= 10.0 // ROE 10% 이상 + val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상 + val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전) + val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유) + val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자 + + return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy + } + + /** + * 종합 상태 반환 (UI 또는 로그용) + */ + fun getInvestmentStatus(fs: FinancialStatement): String { + return when { + isBuyConsiderationMet(fs) -> "🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수" + isSafetyBeltMet(fs) -> "⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족" + else -> "🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업" + } + } + + fun calculateScore(fs: FinancialStatement): Int { + var score = 50.0 // 기본 점수 + + // 성장성 (영업이익 증가율) + score += when { + fs.operatingProfitGrowth > 20 -> 20 + fs.operatingProfitGrowth > 0 -> 10 + else -> -10 // 역성장 시 감점 + } + + // 수익성 (ROE) + score += when { + fs.roe > 15 -> 15 + fs.roe > 5 -> 5 + fs.roe < 0 -> -15 // 적자 시 큰 감점 + else -> 0 + } + + // 안정성 (부채비율) + score += when { + fs.debtRatio < 100 -> 15 + fs.debtRatio < 200 -> 5 + else -> -10 + } + + // 유동성 (당좌비율) + if (fs.quickRatio < 100) score -= 10 // 단기 채무 지급 능력 부족 시 감점 + + return score.coerceIn(0.0, 100.0).toInt() + } +} + data class InvestmentScores( val ultraShort: Int, // 초단기 (분봉/에너지) val shortTerm: Int, // 단기 (일봉/뉴스) val midTerm: Int, // 중기 (주봉/재무) val longTerm: Int // 장기 (월봉/펀더멘털) -) +) { + override fun toString(): String { + return """ + ultraShort $ultraShort + shortTerm $shortTerm + midTerm $midTerm + longTerm $longTerm + """.trimIndent() + } +} class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() diff --git a/src/main/kotlin/service/StockAnalysisManager.kt b/src/main/kotlin/service/StockAnalysisManager.kt deleted file mode 100644 index dd74c8d..0000000 --- a/src/main/kotlin/service/StockAnalysisManager.kt +++ /dev/null @@ -1,40 +0,0 @@ -//package service -// -//import kotlinx.coroutines.async -//import kotlinx.coroutines.coroutineScope -//import model.CandleData -//import model.RealTimeTrade -//import network.NewsService -// -//object StockAnalysisManager { -// var days : List = emptyList() -// var weeks : List = emptyList() -// var monthly : List = emptyList() -// var mins : List = emptyList() -// -// suspend fun analyzeStockWithMultiData(stockCode : String, stockName: String, result : (String)-> Unit) { -// coroutineScope { -// println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...") -// -// val corpInfoDeferred = async { NewsService.fetchCorpInfo(stockCode) } -// val financialDataDeferred = async { NewsService.fetchFinancialGrowth(stockCode) } -// -// val corpInfo = corpInfoDeferred.await() -// val financialData = financialDataDeferred.await() -// -// NewsService.fetchAndIngestNews("$stockName 주가 전망") -// -// println("🧠 [2/3] 관련 컨텍스트 추출 중...") -// -// // 2. 방금 저장된 뉴스를 포함하여 DB에서 관련성 높은 정보 추출 -// val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘." -// val context = RagService.askWithContext(question,corpInfo,financialData,days,weeks,monthly) -// -// println("🤖 [3/3] AI 분석 생성 중 (Chat 서버 8080)...") -// -// // 3. 최종 분석 결과 반환 -// result.invoke(context) -// } -// } -// -//} \ No newline at end of file diff --git a/src/main/kotlin/ui/AutoTradeSettingCard.kt b/src/main/kotlin/ui/AutoTradeSettingCard.kt deleted file mode 100644 index 82a5375..0000000 --- a/src/main/kotlin/ui/AutoTradeSettingCard.kt +++ /dev/null @@ -1,116 +0,0 @@ -//package ui -// -//import AutoTradeItem -//import androidx.compose.foundation.background -//import androidx.compose.foundation.clickable -//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 -//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.delay -//import kotlinx.coroutines.launch -//import model.AppConfig -//import model.BalanceSummary -//import model.CandleData -//import model.RankingStock -//import model.StockHolding -//import network.KisTradeService -//import network.KisWebSocketManager -//import kotlin.collections.isNotEmpty -// -// -//@Composable -//fun AutoTradeSettingCard( -// stockCode: String, -// stockName: String, // 종목명 추가 -// currentPrice: String, -// isDomestic: Boolean = true -//) { -// var profitRate by remember { mutableStateOf("5.0") } -// var stopLossRate by remember { mutableStateOf("-3.0") } -// -// // DB에서 현재 감시 중인지 확인 -// var isEnabled by remember(stockCode) { -// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null) -// } -// -// Card( -// elevation = 4.dp, -// shape = RoundedCornerShape(8.dp), -// backgroundColor = Color(0xFFF8F9FA) -// ) { -// Column(modifier = Modifier.padding(12.dp)) { -// Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2) -// Spacer(modifier = Modifier.height(8.dp)) -// -// Row(verticalAlignment = Alignment.CenterVertically) { -// OutlinedTextField( -// value = profitRate, -// onValueChange = { profitRate = it }, -// label = { Text("익절 %") }, -// modifier = Modifier.weight(1f).padding(end = 4.dp), -// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp) -// ) -// OutlinedTextField( -// value = stopLossRate, -// onValueChange = { stopLossRate = it }, -// label = { Text("손절 %") }, -// modifier = Modifier.weight(1f), -// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp) -// ) -// } -// -// Spacer(modifier = Modifier.height(8.dp)) -// -// Button( -// onClick = { -// if (!isEnabled) { -// // 자동 매매 시작: DB 저장 -// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 -// val target = curPriceNum * (1 + profitRate.toDouble() / 100.0) -// val stopLoss = curPriceNum * (1 + stopLossRate.toDouble() / 100.0) -// -// DatabaseFactory.saveAutoTrade( -// AutoTradeItem( -// code = stockCode, -// name = stockName, -// targetPrice = target, -// stopLossPrice = stopLoss, -// status = "MONITORING", -// isDomestic = isDomestic -// ) -// ) -// // [중요] 웹소켓 실시간 감시 등록 로직이 이곳에 호출되어야 함 -// // KisWebSocketManager.subscribe(stockCode) -// isEnabled = true -// } else { -// // 자동 매매 중단: DB 삭제 -// DatabaseFactory.deleteAutoTrade(stockCode) -// // KisWebSocketManager.unsubscribe(stockCode) -// isEnabled = false -// } -// }, -// modifier = Modifier.fillMaxWidth(), -// colors = ButtonDefaults.buttonColors( -// backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF) -// ) -// ) { -// Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White) -// } -// } -// } -//} \ No newline at end of file diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 34e22fd..8345753 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import model.MAX_BUDGET import model.buyWeight import model.feesAndTaxRate import model.minimumNetProfit @@ -31,42 +32,48 @@ enum class InvestmentGrade( val description: String, val shortWeight: Double = 0.0, val midWeight: Double = 0.0, - val longWeight: Double = 0.0 + val longWeight: Double = 0.0, + val profitGuide: Double = 0.0, ) { LEVEL_5_STRONG_RECOMMEND( displayName = "최상급 추천", description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", shortWeight = 1.0, midWeight = 1.0, - longWeight = 1.0 + longWeight = 1.0, + profitGuide = 1.8, ), LEVEL_4_BALANCED_RECOMMEND( displayName = "균형 추천", description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", shortWeight = 0.8, midWeight = 1.0, - longWeight = 1.0 + longWeight = 1.0, + profitGuide = 1.4, ), LEVEL_3_CAUTIOUS_RECOMMEND( displayName = "보수적 추천", description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", shortWeight = 0.6, midWeight = 1.0, - longWeight = 1.0 + longWeight = 1.0, + profitGuide = 1.0, ), LEVEL_2_HIGH_RISK( displayName = "고위험 추천", description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", shortWeight = 1.0, midWeight = 0.4, - longWeight = 0.4 + longWeight = 0.4, + profitGuide = 0.8, ), LEVEL_1_SPECULATIVE( displayName = "순수 공격적 선택", description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", shortWeight = 1.0, midWeight = 0.2, - longWeight = 0.2 + longWeight = 0.2, + profitGuide = 0.6, ) } @@ -268,18 +275,13 @@ fun IntegratedOrderSection( totalScore : ${totalScore} """.trimIndent()) if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) { - + var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) // 4. 점수에 따른 가변 마진 적용 // 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용 - val finalMargin = if (totalScore >= HIGH_QUALITY_SCORE) { - println("💎 [우량주 포착] 토탈 스코어($totalScore)가 매우 높아 목표 마진을 3.0%로 상향합니다.") - minimumNetProfit * 1.5 - } else { - minimumNetProfit - } + val finalMargin = minimumNetProfit * investmentGrade.profitGuide println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") - val MAX_BUDGET = 35000.0 + // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) val calculatedQty = if (basePrice > 0) { (MAX_BUDGET / basePrice).toInt().coerceAtLeast(1) @@ -291,7 +293,7 @@ fun IntegratedOrderSection( willEnableAutoSell = true, orderQty = calculatedQty.toString(), profitRate1 = finalMargin, - investmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence), + investmentGrade = investmentGrade, ) } else { diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml deleted file mode 100644 index cd3b0c6..0000000 --- a/src/main/resources/logback.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file