This commit is contained in:
2026-02-10 15:08:52 +09:00
parent 4dff629861
commit 4bf055fa68
12 changed files with 302 additions and 399 deletions
-125
View File
@@ -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<RealTimeTrade>
): 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<Double>? {
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<Double>)
/**
* llama.cpp 서버 요청 데이터 구조
*/
@Serializable
data class LlamaRequest(
val prompt: String,
val n_predict: Int = 256, // 답변 길이를 엄격히 제한
val temperature: Double = 0.4, // M3 Pro에서 더 일관된 답변을 위해 낮춤
val stop: List<String> = listOf(
"<|eot_id|>",
"<|end_of_text|>",
"<|start_header_id|>",
"user",
"model"
) // [중요] AI가 멈춰야 할 지점들을 명확히 지정
)
/**
* llama.cpp 서버 응답 데이터 구조
*/
@Serializable
data class LlamaResponse(
val content: String
)
+4 -4
View File
@@ -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
}
+3 -1
View File
@@ -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<TokenResponse> {
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)
}
}
+8 -10
View File
@@ -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<UnifiedBalance> = 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<StockBalanceResponse> {
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<StockBalanceResponse>()
Result.success(body)
} catch (e: Exception) { Result.failure(e) }
}
+78 -7
View File
@@ -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<CIOEngineConfig>(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<DartFinancialResponse>()
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<String, Double> {
val result = mutableMapOf<String, Double>()
// 정규식 설명: 항목명 뒤의 (당기/전기) 괄호 안의 숫자와 콤마를 찾아 숫자로 변환
val regex = Regex("""([가-힣\s()]+)\s\(?$type\)?([-0-9,.]+)""")
regex.findAll(text).forEach { match ->
val key = match.groupValues[1].trim()
val value = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
result[key] = value
}
return result
}
}
}
@Serializable
data class FinancialStatement(
val revenueGrowth: Double = 0.0, // 매출액 증가율
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
val roe: Double = 0.0, // ROE
val debtRatio: Double = 0.0, // 부채비율
val quickRatio: Double = 0.0, // 당좌비율
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
val isNetIncomePositive: Boolean = false
)
+70 -49
View File
@@ -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()