...
This commit is contained in:
parent
1552020d31
commit
44e14dd207
@ -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자리 법인코드 반환
|
||||
*/
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
9150
stocks_universe.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user