This commit is contained in:
lunaticbum 2026-03-16 17:07:25 +09:00
parent 1552020d31
commit 44e14dd207
4 changed files with 9241 additions and 55 deletions

View File

@ -3,6 +3,8 @@ package network
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.KisSession
import model.RankingStock
import service.AutoTradingManager
@ -11,6 +13,36 @@ import java.io.File
import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
@Serializable
data class StockItem(
val code: String,
val name: String
){}
object StockUniverseLoader {
private val json = Json { ignoreUnknownKeys = true }
fun loadUniverse(filePath: String = "stocks_universe.json"): List<Pair<String, String>> {
return try {
val file = File(filePath)
if (!file.exists()) {
println("⚠️ 파일을 찾을 수 없습니다: ${file.absolutePath}")
return emptyList()
}
// 1. JSON 파일 읽기 및 역직렬화
val rawJson = file.readText()
val stockItems = json.decodeFromString<List<StockItem>>(rawJson)
// 2. Pair(코드, 이름) 튜플 리스트로 변환
stockItems.map { it.code to it.name }
} catch (e: Exception) {
println("❌ 유니버스 로드 실패: ${e.message}")
emptyList()
}
}
}
data class CorpInfo(
var cCode : String = "",
@ -83,6 +115,7 @@ object DartCodeManager {
}
}
fun getStockCodez() : Array<String> = corpCodeMap.keys.toTypedArray()
/**
* 6자리 종목코드로 8자리 법인코드 반환
*/

View File

@ -26,6 +26,15 @@ import service.UrlCacheManager
import java.nio.file.Paths
import java.time.Duration
interface TradingAnalyst {
@dev.langchain4j.service.SystemMessage("""
You are a Senior Stock Analyst.
Analyze the data and provide a decision in JSON format.
You must respond ONLY with a valid JSON object.
""")
fun analyzeStock(@dev.langchain4j.service.UserMessage prompt: String): TradingDecision
}
object RagService {
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
@ -39,8 +48,15 @@ object RagService {
.apiKey("unused")
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
.timeout(Duration.ofSeconds(60))
.frequencyPenalty(1.1)
.maxTokens(500) // 👈 루프 방지를 위해 반드시 짧게 제한!
// 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요
.responseFormat("json_object")
.build()
private val analyst = dev.langchain4j.service.AiServices.builder(TradingAnalyst::class.java)
.chatModel(chatModel)
.build()
private val embeddingStore: LuceneEmbeddingStore by lazy {
val path = Paths.get("db/lucene_idx")
@ -63,6 +79,7 @@ object RagService {
}
/**
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
*/
@ -255,52 +272,31 @@ object RagService {
tempDecision: TradingDecision
): TradingDecision? {
val prompt = """
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
당신은 정량적 수치와 정성적 뉴스를 통합 분석하는 'AI 수석 애널리스트'입니다.
시스템이 계산한 지표 점수와 실제 재무제표 요약본을 바탕으로 최종 매매 전략을 수립하십시오.
# Task: Senior AI Investment Analyst
Analyze the stock '$stockName' and determine the final trading decision based on the data below.
[종목 정보]
- 종목명: $stockName
# Data
1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm})
2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}%
3. News Context: ${tempDecision.newsContext?.take(400)} // 👈 뉴스 길이를 물리적으로 제한
[1. 시스템 산출 스코어 (0-100)]
- 초단기(Scalping): ${scores.ultraShort}
- 단기(Daily): ${scores.shortTerm}
- 중기(Weekly): ${scores.midTerm}
- 장기(Monthly): ${scores.longTerm}
# Constraints
1. 모든 점수와 confidence는 0에서 100 사이의 **정수(Integer)**로만 작성하십시오.
- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD).
- Synchronization: High scalping score + positive news momentum = Higher BUY confidence.
- Output: Response ONLY in valid JSON format. No extra text.
[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 데이터만 출력하십시오. 설명이나 서론은 생략합니다.
- 반드시 아래 형식을 엄격히 준수하십시오.
{
"ultraShortScore": ${scores.ultraShort},
"shortTermScore": ${scores.shortTerm},
"midTermScore": ${scores.midTerm},
"longTermScore": ${scores.longTerm},
"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()
# Output JSON Format (Reason must be in Korean)
{
"ultraShortScore": ${scores.ultraShort},
"shortTermScore": ${scores.shortTerm},
"midTermScore": ${scores.midTerm},
"longTermScore": ${scores.longTerm},
"decision": "BUY/SELL/HOLD",
"reason": "재무와 뉴스를 대조한 분석 결과 (한국어)",
"confidence": 0~100
}
""".trimIndent()
val response = chatModel.chat(UserMessage.from(prompt))
val rawResponse = response.aiMessage().text()

View File

@ -32,6 +32,7 @@ import network.FinancialStatement
import network.KisAuthService
import network.KisTradeService
import network.KisWebSocketManager
import network.StockUniverseLoader
import util.MarketUtil
import java.time.LocalDateTime
import java.time.LocalTime
@ -51,10 +52,10 @@ object AutoTradingManager {
private val lastTickTime = AtomicLong(System.currentTimeMillis())
private var watchdogJob: Job? = null
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
private const val CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
private const val ONE_STOCK_ALYSIS_TIME = 90000L
private const val STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
private const val ONE_STOCK_ALYSIS_TIME = 180000L
fun isRunning(): Boolean = discoveryJob?.isActive == true
private var remainingCandidates = mutableListOf<RankingStock>()
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
@ -122,7 +123,7 @@ object AutoTradingManager {
} else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) {
addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어[$totalScore] 또는 신뢰도[${completeTradingDecision.confidence}] 미달 이나 약간의 오차로 재분석 대기열에 추가")
} else {
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
}
@ -432,12 +433,15 @@ object AutoTradingManager {
val balance = tradeService.fetchIntegratedBalance().getOrNull()
balance?.let { resumePendingSellOrders(tradeService, it) }
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings =
balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet()
?: emptySet()
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
// [프로세스 2] 후보군 수집
if (remainingCandidates.isEmpty()) {
val stocks = StockUniverseLoader.loadUniverse()
println("✅ 총 ${stocks.size}개의 종목을 로드했습니다.")
stocks.forEach { (code, name) ->
// println("📌 로드됨: [$code] $name")
addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name))
}
val candidates: MutableList<RankingStock> = fetchCandidates(tradeService).apply {
println("후보군 총 개수 : $size")
}.filter {
@ -458,12 +462,14 @@ object AutoTradingManager {
candidates.addAll(reanalysisList.asReversed())
}
reanalysisList.clear()
remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks }
.distinctBy { it.code })
remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList}
.distinctBy { it.code }.shuffled())
} else {
println("미확인 데이터 ${remainingCandidates.size}")
// remainingCandidates.removeIf { it.code in myHoldings || it.code in pendingStocks || it.code in executionCache.values.map { it.code } || it.code in failList}
}
var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator()
@ -553,10 +559,10 @@ object AutoTradingManager {
if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지
retryCountMap[stock.code] = count + 1
reanalysisList.add(stock)
println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
// println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
}
}
val failList = arrayListOf<String>()
private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) {
try {
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
@ -578,6 +584,7 @@ object AutoTradingManager {
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
val today = dailyData.lastOrNull() ?: null
if (today == null) {
failList.add(stock.code)
print("-> 금일 금액 조회 실패 | ")
return@withTimeout
}

9150
stocks_universe.json Normal file

File diff suppressed because it is too large Load Diff