This commit is contained in:
lunaticbum 2026-04-08 14:18:09 +09:00
parent b95c1d5f72
commit 6494784bbc
7 changed files with 471 additions and 274 deletions

View File

@ -0,0 +1,80 @@
package analyzer
import model.CandleData
import service.InvestmentGrade
object AdvancedTradeAssistant {
// 1. VWAP (거래량 가중 평균 단가) 계산기
// 주로 최근 30분(min30) 데이터를 받아 초단기 세력 평단가를 구합니다.
fun calculateMicroVWAP(candles: List<CandleData>): Double {
if (candles.isEmpty()) return 0.0
var typicalVolumeSum = 0.0
var totalVolume = 0.0
for (candle in candles) {
val typicalPrice = (candle.stck_hgpr.toDouble() + candle.stck_lwpr.toDouble() + candle.stck_prpr.toDouble()) / 3
val volume = candle.cntg_vol.toDouble()
typicalVolumeSum += typicalPrice * volume
totalVolume += volume
}
return if (totalVolume == 0.0) 0.0 else typicalVolumeSum / totalVolume
}
// 2. 볼린저 밴드 하단선 계산기 (20일선 기준)
fun calculateBollingerLowerBand(candles: List<CandleData>, period: Int = 20): Double {
if (candles.size < period) return 0.0
val targetCandles = candles.takeLast(period).map { it.stck_prpr.toDouble() }
val ma20 = targetCandles.average()
// 표준편차 계산
val variance = targetCandles.map { Math.pow(it - ma20, 2.0) }.average()
val stdDev = Math.sqrt(variance)
// 하단선 = 20일 이동평균선 - (2 * 표준편차)
return ma20 - (2 * stdDev)
}
// 3. 🎯 특정 그레이드에 맞춤형 '매수 조언(Confirmation)' 제공
fun confirmTrade(
currentGrade: InvestmentGrade,
currentPrice: Double,
min30: List<CandleData>,
daily: List<CandleData>
): TradeAdvice {
return when (currentGrade) {
// [초단타 등급] VWAP 필터 적용
InvestmentGrade.LEVEL_1_SPECULATIVE, InvestmentGrade.LEVEL_2_HIGH_RISK -> {
val vwap = calculateMicroVWAP(min30)
if (currentPrice >= vwap) {
// 현재가가 세력 평단가(VWAP) 위에서 놀고 있음 -> 매수 확정 및 가산점
TradeAdvice(isConfirmed = true, confidenceBonus = +5.0, reason = "VWAP 돌파(강한 수급 방어)")
} else {
// 투매 구간 (세력 평단가 이탈) -> 진입 포기 (LEVEL_0으로 강등 권고)
TradeAdvice(isConfirmed = false, confidenceBonus = -20.0, reason = "VWAP 하향 이탈(투매 위험)")
}
}
// [우량주 눌림목 등급] 볼린저 밴드 필터 적용
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND, InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> {
val lowerBand = calculateBollingerLowerBand(daily)
// 💡 [수정] 1.05배, 1.15배 디테일 추가
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05) {
TradeAdvice(isConfirmed = true, confidenceBonus = +8.0, reason = "볼린저 밴드 하단 터치(통계적 바닥 확인)")
} else if (lowerBand > 0 && currentPrice > lowerBand * 1.15) {
TradeAdvice(isConfirmed = true, confidenceBonus = -5.0, reason = "볼린저 밴드 하단 미도달(추가 하락 가능성)")
} else {
TradeAdvice(isConfirmed = true, confidenceBonus = 0.0, reason = "정상 추세 구간")
}
}
else -> TradeAdvice(isConfirmed = true, confidenceBonus = 0.0, reason = "추가 검증 없음")
}
}
}
// 조언 결과를 담는 데이터 클래스
data class TradeAdvice(
val isConfirmed: Boolean,
val confidenceBonus: Double,
val reason: String
)

View File

@ -62,6 +62,17 @@ object FinancialAnalyzer {
return buffer.toString()
}
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
}
fun calculateScore(fs: FinancialStatement): Int {
var score = 50.0 // 중립 시작
@ -101,106 +112,3 @@ object FinancialAnalyzer {
}
}
object FinancialAnalyzer2 {
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
val isNotCrashing = fs.netIncomeGrowth > -40.0
return isDebtSafe && isLiquiditySafe && isNotDeficit && isNotCrashing
}
/**
* [매수 고려] 우량 기업 요건 확인
* 모든 조건 충족 적극적인 분석(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
}
fun toString(fs : FinancialStatement): String {
var buffer = StringBuffer()
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
val isNotCrashing = fs.netIncomeGrowth > -40.0
if ((isDebtSafe && isLiquiditySafe && isNotDeficit) == false) {
if (!isDebtSafe)buffer.appendLine( "부채비율 200% 이상")
if (!isLiquiditySafe)buffer.appendLine( "당좌비율 80% 미만")
if (!isNotDeficit)buffer.appendLine( "당기순이익 적자")
if (!isNotCrashing) { buffer.appendLine("당기순이익 급감(${String.format("%.1f", fs.netIncomeGrowth)}%)") }
buffer.appendLine("최소 기준 미달")
} else {
buffer.appendLine("최소 기준 충족")
}
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 // 본업(영업이익)이 흑자
if ((highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy) == false) {
if(!highProfitability) buffer.appendLine( "ROE 10% 미만")
if(!strongGrowth) buffer.appendLine( "이익 성장률 15% 미만")
if(!verySafeDebt) buffer.appendLine( "부채비율 100% 이상 (안전성 미달)")
if(!goodLiquidity) buffer.appendLine( "당좌비율 120% 이하 (여유 없음)")
if(!businessHealthy) buffer.appendLine( "본업(영업이익)이 적자")
buffer.appendLine("재무 건전성 및 성장성 미달")
} else {
buffer.appendLine("재무 건전성 및 성장성 충족")
}
return buffer.toString()
}
/**
* 종합 상태 반환 (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()
}
}

View File

@ -12,7 +12,7 @@ data class InvestmentScores(
val ultraShort: Int, // 초단기 (분봉/에너지)
val shortTerm: Int, // 단기 (일봉/뉴스)
val midTerm: Int, // 중기 (주봉/재무)
val longTerm: Int // 장기 (월봉/펀더멘털)
val longTerm: Int // 장기 (월봉/펀더멘털)
) {
override fun toString(): String {
return """
@ -28,15 +28,16 @@ data class InvestmentScores(
@Serializable
class TechnicalAnalyzer {
var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
// 주의: min30은 '30분봉'이 아니라 '1분 단위 캔들 30개'를 의미합니다.
var min30: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var monthly: List<CandleData> = emptyList()
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
/**
* [신규] 기술적 지표와 추세를 결합한 종합 신호 생성
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
*/
fun generateComprehensiveSignal(): ScalpingSignalModel {
val scalpingAnalyzer = ScalpingAnalyzer()
@ -44,10 +45,9 @@ class TechnicalAnalyzer {
// 1. 기본 스캘핑 신호 생성
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
// 2. 점수 정교화 (가점/감점 요인)
var refinedScore = baseSignal.compositeScore.toDouble()
// 2. 점수 정교화 (가점/감점 요인)
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
refinedScore += 10.0
@ -67,20 +67,64 @@ class TechnicalAnalyzer {
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
if (bodyRange > atr * 1.2) refinedScore += 7.0
// 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석
if (min30.size >= 15) {
val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들)
// ① 초단기 추세 가감점 (최근 5분간 변화율)
val microChange = calculateChange(last5Candles)
if (microChange > 1.5) refinedScore += 6.0 // 순간 급등세 (수급 유입)
else if (microChange < -1.5) refinedScore -= 12.0 // 순간 투매 방어 (강력 감점)
// ② 초단기 거래량 급증 (V-Spike) 확인
val recentVolume = last5Candles.map { it.cntg_vol.toDouble() }.average()
val pastVolume = min30.dropLast(5).takeLast(10).map { it.cntg_vol.toDouble() }.average() // 그 이전 10분 평균
if (pastVolume > 0 && recentVolume > pastVolume * 2.5) {
// 평소보다 거래량이 2.5배 터졌을 때, 양봉이면 매수세 폭발, 음봉이면 쏟아내는 투매 물량
val isBullishMicro = lastCandle.stck_prpr.toDouble() >= lastCandle.stck_oprc.toDouble()
if (isBullishMicro) refinedScore += 8.0
else refinedScore -= 10.0
}
// ③ 초단기 RSI 과열/과매도 필터링
val rsiMicro = calculateRSI(last5Candles)
if (rsiMicro > 75.0) refinedScore -= 8.0 // 초단기 꼭지 (추격매수 방지)
else if (rsiMicro < 25.0) refinedScore += 5.0 // 초단기 투매 과도 (기술적 반등 타점 노림)
}
return baseSignal.copy(
compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(),
successProbPct = (refinedScore * 0.85).coerceAtMost(98.0)
)
}
/**
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직
*/
fun isOverheatedStock(): Boolean {
if (min30.size < 20 || daily.size < 20) return false
val currentPrice = min30.last().stck_prpr.toDouble()
if (daily.size < 20) return false
val currentPrice = daily.last().stck_prpr.toDouble()
// 1. 일봉 20일선 이격도
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
val disparityDaily = (currentPrice / ma20Daily) * 100
// 이격도 115% 이상이면 주의, 125% 이상이면 과열
return disparityDaily > 115.0
// 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인)
val rsiDaily = calculateRSI(daily)
// 3. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용)
var isMicroOverheated = false
if (min30.size >= 10) {
val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average()
val disparityMin = (currentPrice / ma10Min) * 100
isMicroOverheated = disparityMin > 105.0 // 10분 평균가 대비 순간적으로 5% 이상 폭등 시
}
// 단순히 이격도 115%라고 막는 것이 아니라 3가지 깐깐한 조건 중 하나라도 충족될 때만 과열 판정
return disparityDaily > 130.0 || // (A) 역대급 폭등 상태
(disparityDaily > 115.0 && rsiDaily > 75.0) || // (B) 급등 중이면서 일봉 과매수
(disparityDaily > 115.0 && isMicroOverheated) // (C) 급등 중이면서 10분 내 순간 펌핑(꼭지)
}
fun calculateScores(financialScore100: Int): InvestmentScores {
@ -100,7 +144,7 @@ class TechnicalAnalyzer {
)
}
// --- 유틸리티 함수군 (기존 로직 유지 및 보완) ---
// --- 이하 유틸리티 함수군 (변경 없음) ---
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
if (candles.size < period + 1) return 0.0
val sub = candles.takeLast(period + 1)
@ -170,14 +214,14 @@ class TechnicalAnalyzer {
val signal = generateComprehensiveSignal()
val currentPrice = min30.last().stck_prpr.toDouble()
// [표준화된 분석 점수] - AI에게 가이드라인 제공
// [표준화된 분석 점수]
val standardizedScores = """
- Financial Health Score: $finScore100 / 100
- Technical Momentum Score: ${signal.compositeScore} / 100
- Market Energy (Volume): ${"%.1f".format(signal.volRatio)}x relative to avg
""".trimIndent()
// [시계열 가격 흐름] - AI에게 지지와 저항 맥락 제공
// [시계열 가격 흐름]
val monthlyRange = monthly.takeLast(3).joinToString(" -> ") {
"[H:${it.stck_hgpr}, L:${it.stck_lwpr}]"
}
@ -188,15 +232,13 @@ class TechnicalAnalyzer {
return """
# [Standardized Analysis Summary]
$standardizedScores
# [Historical Price Range]
- Monthly (Last 3M): $monthlyRange
- Weekly (Last 4W): $weeklyRange
- Current Price: $currentPrice
# [Technical Context]
- Base Position: ${"%.1f".format((currentPrice / daily.takeLast(120).map { it.stck_prpr.toDouble() }.average()) * 100)}% (120MA)
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
""".trimIndent()
}
}
}

View File

@ -501,6 +501,22 @@ object TradingLogStore {
}
}
fun addNotice(name : String, code : String, log: String) {
synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
decisionLogs.add(
LogEntry(
time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
stockName = "$name[$code] 분석",
decision = "NOTICE",
confidence = 100.0,
reason = log
)
)
}
}
fun addAfterMarketLog(name : String, code : String, log: String) {
synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)

View File

@ -3,6 +3,7 @@ package network// src/main/kotlin/network/RagService.kt
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
import TradingLogStore
import analyzer.AdvancedTradeAssistant
import analyzer.FinancialAnalyzer
import analyzer.FinancialMapper
import analyzer.FinancialStatement
@ -55,6 +56,7 @@ import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
//interface TradingAnalyst {
@ -67,6 +69,13 @@ import java.util.concurrent.TimeUnit
//}
object RagService {
val isSafetyBeltStockCodes = ConcurrentHashMap.newKeySet<String>()
// (매일 아침 8시 30분 시스템 초기화 시 호출해주어야 함)
fun clearDailyCache() {
isSafetyBeltStockCodes.clear()
println("🧹 [System] 일일 재무 미달 캐시 초기화 완료")
}
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
private val embeddingModel = OpenAiEmbeddingModel.builder()
@ -237,6 +246,13 @@ object RagService {
this.currentPrice = currentPrice
}
if (isSafetyBeltStockCodes.contains(stockCode)) {
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
return@coroutineScope
}
// [1단계] 재무 분석 및 필터링 (가장 빠름)
val finStartTime = System.currentTimeMillis()
val financialData = NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)?.cCode)
@ -256,12 +272,22 @@ object RagService {
if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime)
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false)
isSafetyBeltStockCodes.add(stockCode)
return@coroutineScope
}
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
result(tradingDecision.apply { decision = "HOLD"; reason = "매수 타점 미도달" }, false)
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) {
TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true)
result(tradingDecision.apply {
decision = "RETRY" // 콜백에서 "BUY"가 아니므로 HOLD와 동일하게 취급됨
reason = "매수 타점 미도달 (재무 우량주로 감시 지속)"
confidence = 65.0 // AutoTradingManager의 재분석 기준(60.0)을 넘기기 위해 부여
}, true) // isSuccess를 true로 주어야 콜백이 무시하지 않음
} else {
result(tradingDecision.apply { decision = "HOLD"; reason = "매수 타점 미도달" }, false)
}
return@coroutineScope
}
@ -286,8 +312,8 @@ object RagService {
tradingDecision.newsContext = finalSearchResult.matches().distinct() // 중복 제거
.take(4) // 10개에서 4개로 축소
.joinToString("\n\n") {
it.embedded().text()
}
it.embedded().text()
}
val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision)
val ragAiDuration = System.currentTimeMillis() - ragStartTime
@ -442,7 +468,7 @@ object RagService {
// 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간)
val newsStartTime = System.currentTimeMillis()
val (newsScore100, newsReason) = tempDecision.newsContext?.let {
getAiNewsScore(it, tempDecision.techSummary ?: "")
getAiNewsScore(stockName,it, tempDecision.techSummary ?: "")
} ?: (50.0 to "참조 뉴스 없음")
val newsDuration = System.currentTimeMillis() - newsStartTime
@ -460,14 +486,41 @@ object RagService {
if (isOverheated) finalConfidence *= 0.85
val totalScore = (scores.ultraShort + scores.shortTerm + scores.midTerm + scores.longTerm) / 4.0
val grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence)
val synthDuration = System.currentTimeMillis() - synthStartTime
tempDecision.ultraShortScore = scores.ultraShort.toDouble()
tempDecision.shortTermScore = scores.shortTerm.toDouble()
tempDecision.midTermScore = scores.midTerm.toDouble()
tempDecision.longTermScore = scores.longTerm.toDouble()
// 5. 최종 결정 및 사유 정리
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
var finalDecision = "HOLD"
var finalReason = ""
var grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence, finScore100)
var assistantReason = ""
if (grade != InvestmentGrade.LEVEL_0_SPECULATIVE) {
val advice = AdvancedTradeAssistant.confirmTrade(
currentGrade = grade,
currentPrice = tempDecision.currentPrice,
min30 = tempDecision.analyzer?.min30 ?: emptyList(),
daily = tempDecision.analyzer?.daily ?: emptyList()
)
finalConfidence += advice.confidenceBonus
if (!advice.isConfirmed) {
grade = InvestmentGrade.LEVEL_0_SPECULATIVE
assistantReason = " 🚫 [어시스턴트 차단] ${advice.reason}"
} else if (advice.reason.isNotEmpty()) { // 💡 조건 변경
assistantReason = " [확인됨: ${advice.reason}]"
}
}
val synthDuration = System.currentTimeMillis() - synthStartTime
when {
newsScore100 < 30.0 -> {
finalDecision = "HOLD"
@ -477,9 +530,9 @@ object RagService {
finalDecision = "HOLD"
finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한"
}
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_1_SPECULATIVE -> {
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_0_SPECULATIVE -> {
finalDecision = "BUY"
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수"
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수 | $assistantReason"
}
finalConfidence < 40.0 -> {
finalDecision = "SELL"
@ -497,6 +550,9 @@ object RagService {
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
return TradingDecision().apply {
this.technicalScore = techScore100
this.financialScore = finScore100
this.systemScore = sysScore100
this.stockCode = tempDecision.stockCode
this.stockName = stockName
this.currentPrice = tempDecision.currentPrice
@ -520,9 +576,11 @@ object RagService {
}
private suspend fun getAiNewsScore(news: String,techSummary : String): Pair<Double, String> {
private suspend fun getAiNewsScore(stockName:String , news: String,techSummary : String): Pair<Double, String> {
val prompt = """
# Role: Expert Quantitative & Sentiment Analyst
# Target Stock: [$stockName]
# Task: Evaluate [News Text] by correlating it with [Market Context].
# Input 1: [Market Context] (Standardized Scores & Price History)
@ -543,7 +601,11 @@ object RagService {
- If 'Base Position' is near 100% (at 120MA), consider it a 'Safe Entry' for long-term holding.
# Constraints:
- Reason: KOREAN only, max 50 chars. Explain the "Synergy" between scores and news.
- Reason: KOREAN only, max 50 chars. Explain the "Synergy" between scores and news.
1. Target Isolation: You MUST ONLY extract facts related EXACTLY to [$stockName].
2. No Mix-up: Do NOT attribute actions of other companies (e.g., unrelated capital increases or earnings of competitors) to [$stockName].
3. Verify Numbers: Check if [$stockName]'s YoY profit is Positive (+) or Negative (-). If Negative, you MUST reflect it as a penalty in the score.
- Output: Strictly JSON format.
# JSON Output:
@ -608,6 +670,9 @@ class TradingDecision {
var reason: String? = null
var confidence: Double = 0.0
var newsScore : Double = 0.0
var systemScore : Double = 0.0
var financialScore : Double = 0.0
var technicalScore : Double = 0.0
var investmentGrade : InvestmentGrade? = null
var techSummary : String? = null
var newsContext : String? = null
@ -630,6 +695,26 @@ class TradingDecision {
midTermScore,
longTermScore).average()
fun summary() : String{
return """
$corpName[$stockName]
수익실현 가능성 : ${profitPossible()}
investmentGrade:${investmentGrade!!.name}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
systemScore :$systemScore
technicalScore: $technicalScore
financialScore: $financialScore
newsScore: $newsScore
decision: $decision
reason: $reason
""".trimIndent()
}
override fun toString(): String {
return """
$corpName($stockName)
@ -639,12 +724,11 @@ shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
decision: $decision
investmentGrade:${investmentGrade!!.name}
reason: $reason
confidence: $confidence
기술 분석: $techSummary
뉴스 점수: $newsScore
""".trimIndent()
}
}

View File

@ -7,10 +7,10 @@ import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
import network.TradingDecision
import TradingLogStore
import analyzer.AdvancedTradeAssistant
import analyzer.TechnicalAnalyzer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope
@ -25,31 +25,27 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import model.CandleData
import model.ConfigIndex
import model.ExecutionData
import model.KisSession
import model.RankingStock
import model.RankingType
import model.UnifiedBalance
import model.UnifiedStockHolding
import network.DartCodeManager
import network.KisAuthService
import network.KisTradeService
import network.KisWebSocketManager
import network.RagService
import network.StockUniverseLoader
import org.jetbrains.skia.ImageFilter
import util.MarketUtil
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.atomic.AtomicLong
import kotlin.collections.List
import kotlin.math.*
// service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
object AutoTradingManager {
@ -88,7 +84,7 @@ object AutoTradingManager {
val nowDate = LocalDate.now(seoulZone)
var checkTime = 60_000 * 3L
val isTradingDay = nowDate.dayOfWeek.value in 1..5
if (isTradingDay && now.isAfter(H08M30) && now.isBefore(H18) && !shouldShowFullWindow) {
if (isTradingDay && now.isAfter(H07M50) && now.isBefore(H18) && !shouldShowFullWindow) {
shouldShowFullWindow = true
SystemSleepPreventer.wakeDisplay()
} else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) {
@ -102,7 +98,7 @@ object AutoTradingManager {
}
}
// val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
// val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
// if (isSuccess && completeTradingDecision != null) {
// // 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
// TradingLogStore.addLog(completeTradingDecision)
@ -188,79 +184,91 @@ object AutoTradingManager {
// }
// }
// }
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) {
val decision = completeTradingDecision
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) {
val decision = completeTradingDecision
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
if (decision.decision == "BUY") {
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
if (decision.decision == "BUY") {
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
// 2. 최종 매수 실행
val gradeRate = KisSession.config.getValues(grade.allocationRate)
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
excuteTrade(
decision = decision,
orderQty = calculatedQty.toString(),
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
investmentGrade = grade
)
} else if (decision.confidence >= 60.0) { // 아까운 종목만 재분석
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
// 2. 최종 매수 실행
val gradeRate = KisSession.config.getValues(grade.allocationRate)
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
TradingLogStore.addLog(decision,"BUY",decision.summary())
excuteTrade(
decision = decision,
orderQty = calculatedQty.toString(),
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
investmentGrade = grade
)
} else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
}
}
}
}
val MIN_CONFIDENCE = 60.0 // 최소 신뢰도
var append = 0.0
fun getInvestmentGrade(
ts: TradingDecision,
totalScore: Double,
confidence: Double
confidence: Double,
finScore100: Double // 💡 [수정1] 컴파일 에러 방지용 파라미터 추가
): InvestmentGrade {
// [개선] 하드코딩된 60/70 대신 사용자 설정 최소 점수를 기준으로 사용
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
val minConfidence = minScore // 신뢰도 하한선도 매수 기준 점수와 동기화
val minConfidence = minScore
// 1. 최소 기준 미달 시 (관망 대상)
if (totalScore < (minScore * 0.8) || confidence < minConfidence) {
return InvestmentGrade.LEVEL_1_SPECULATIVE
return InvestmentGrade.LEVEL_0_SPECULATIVE
}
// 2. 패턴 점수 추출
val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0
val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0
val isOverheated = ts.analyzer?.isOverheatedStock() ?: true
// 3. [개선] 점수 구간을 5~10점씩 하향 조정하여 실제 '추천' 등급이 나오도록 보정
val rawGrade = when {
// [A그룹] 중장기 추세가 강한 상태
midLongAvg >= 70.0 -> { // 75 -> 70 하향
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // 80 -> 75
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 70 -> 65
// 1. 기본 등급 산정
var rawGrade = when {
midLongAvg >= 70.0 -> {
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
}
// [B그룹] 중장기 추세가 보통인 상태
midLongAvg >= 60.0 -> { // 65 -> 60 하향
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 75 -> 70
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // 65 -> 60
midLongAvg >= 60.0 -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
else InvestmentGrade.LEVEL_2_HIGH_RISK
}
// [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태
else -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
else InvestmentGrade.LEVEL_1_SPECULATIVE
}
}
// 4. 단기 과열 패널티 (일괄 1단계 강등)
// 💡 [수정2] 누락되었던 우량주 눌림목 프리미엄 & 잡주 투매 회피 로직 추가
val isHealthy = finScore100 >= 70.0
val isPullback = midLongAvg >= 75.0 && shortAvg <= 45.0
if (isHealthy && isPullback) {
rawGrade = when (rawGrade) {
InvestmentGrade.LEVEL_1_SPECULATIVE,
InvestmentGrade.LEVEL_2_HIGH_RISK -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else -> rawGrade
}
} else if (!isHealthy && isPullback) {
rawGrade = when (rawGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND,
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_1_SPECULATIVE
InvestmentGrade.LEVEL_2_HIGH_RISK,
InvestmentGrade.LEVEL_1_SPECULATIVE -> InvestmentGrade.LEVEL_0_SPECULATIVE
else -> InvestmentGrade.LEVEL_0_SPECULATIVE
}
}
return if (isOverheated) {
when (rawGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
@ -273,10 +281,11 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
}
}
fun excuteTrade(decision: TradingDecision,orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
scope.launch {
var basePrice = decision.currentPrice
val tickSize = MarketUtil.getTickSize(basePrice)
// 등급별 가이드에 따라 매수 호가 설정
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
var stockCode = decision.stockCode
var stockName = decision.stockName
@ -284,15 +293,16 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice")
TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo")
val pRate = 0.4
val sRate = -1.5
.onSuccess { realOrderNo ->
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
// 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장)
val sRate = -1.5
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues(
ConfigIndex.PROFIT_INDEX) + tax))
// 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기
val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
@ -303,7 +313,7 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = effectiveProfitRate, // 보정된 수익률 저장
profitRate = effectiveProfitRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
@ -311,7 +321,9 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
isDomestic = true
))
syncAndExecute(realOrderNo)
TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
}
.onFailure {
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
@ -347,16 +359,13 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05
val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
// 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
KisTradeService.postOrder(
@ -422,10 +431,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
var targetPrice = holding.currentPrice.toDouble()
TradingLogStore.addAfterMarketLog(
holding.name,
holding.code,
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
)
holding.name,
holding.code,
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
)
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
@ -452,6 +461,8 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
"🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
)
}
} else {
analyzeDeepLossHoldingsAfterMarket(holding)
}
delay(300) // API 호출 부하 방지
}
@ -460,68 +471,113 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
// if (isRunning()) return
val now = LocalTime.now()
val currentMinute = now.minute
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
println("resumePendingSellOrders")
balance.holdings.forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
} else {
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
var targetPrice = holding.currentPrice.toDouble()
val now = LocalTime.now()
val currentMinute = now.minute
var isBefore930 = false
if (now.hour == 9 && currentMinute < 30) {
targetPrice = targetPrice
isBefore930 = true
} else {
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
}
println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
).onSuccess { newOrderNo ->
println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
)
}.onFailure {
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 보유 주식 매도 주문 실패[${it.message}] "
)
}
println("resumePendingSellOrders")
balance.holdings.forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
} else {
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
var targetPrice = holding.currentPrice.toDouble()
val now = LocalTime.now()
val currentMinute = now.minute
var isBefore930 = false
if (now.hour == 9 && currentMinute < 30) {
targetPrice = targetPrice
isBefore930 = true
} else {
TradingLogStore.addAnalyzer(
"보유주식[${holding.name}]",
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
}
println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
).onSuccess { newOrderNo ->
println("✅ [보유 주식 주문 완료] ${holding.name}: $newOrderNo")
TradingLogStore.addSellLog(
holding.code,
"수익률 미달 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
targetPrice.toString(),
"SELL",
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
)
}.onFailure {
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 보유 주식 매도 주문 실패[${it.message}] "
)
}
delay(200) // API 호출 부하 방지
} else {
analyzeDeepLossHoldingsAfterMarket(holding)
}
delay(200) // API 호출 부하 방지
}
}
// }
}
private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
val now = LocalTime.now()
val currentMinute = now.minute
if ((now.hour == 8 || now.hour == 16 || now.hour == 17)) {
val profit = holding.profitRate.toDouble()
val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다)
if (profit <= lossThreshold) {
println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...")
// 차트 데이터 빠르게 가져오기 (일봉 위주로 큰 추세만 확인)
val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull()
if (!dailyData.isNullOrEmpty()) {
val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
val currentPrice = holding.currentPrice.toDouble()
// 1. 볼린저 밴드 하단선 (통계적 바닥) 확인
val lowerBand = AdvancedTradeAssistant.calculateBollingerLowerBand(dailyData)
// 2. RSI 확인 (과매도 투매 상태인지)
val rsiDaily = analyzer.calculateRSI(dailyData)
// 3. 중기 추세 확인 (최근 20일 기준 10% 이상 하락했는지)
val isTrendBroken = analyzer.calculateChange(dailyData.takeLast(20)) < -10.0
var advice = ""
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) {
advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
}
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
else if (isTrendBroken && currentPrice > lowerBand * 1.1) {
advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다."
}
// 🟡 [관망] 어정쩡하게 물려있는 상태
else {
advice = "⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다."
}
// 분석 결과를 UI 로그에 띄워 대표님이 확인할 수 있게 함
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률 심각($profit%) -> $advice",
)
}
} else {
// -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김
// TradingLogStore.addAnalyzer("보유주식[${holding.name}]", holding.code, "수익률 미달 대기중 (${profit}%)")
}
}
}
var isSystemReadyToday = false
var isSystemCleanedUpToday = false
private var lastRetryTime = 0L
@ -567,9 +623,9 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
var waitTime = 0.2
val H16 = LocalTime.of(16, 0)
val H18 = LocalTime.of(18, 0)
val H08M35 = LocalTime.of(8, 0)
val H08M00 = LocalTime.of(8, 0)
val H08M45 = LocalTime.of(8, 45)
val H08M30 = LocalTime.of(7, 50)
val H07M50 = LocalTime.of(7, 50)
private fun runDiscoveryLoop(callback: TradingDecisionCallback) {
discoveryJob = scope.launch {
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
@ -579,10 +635,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
when {
now.isAfter(H18) || now.isBefore(H08M35) -> {
now.isAfter(H18) || now.isBefore(H08M00) -> {
prepareMarketOpen(now)
}
now.isBefore(H18) && now.isAfter(H08M35) -> {
now.isBefore(H18) && now.isAfter(H08M00) -> {
waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
@ -624,9 +680,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
}
suspend fun prepareMarketOpen(now : LocalTime) {
if (now.isAfter(H18) || now.isBefore(H08M30)) {
if (now.isAfter(H18) || now.isBefore(H07M50)) {
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
onMarketClosed?.invoke()
RagService.clearDailyCache()
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0)
LlamaServerManager.stopAll() // AI 서버 완전 종료
@ -634,7 +691,7 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
isSystemReadyToday = false
shouldShowFullWindow = false
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if (now.isAfter(H08M30) && now.isBefore(H08M35) && !isSystemReadyToday) {
} else if (now.isAfter(H07M50) && now.isBefore(H08M00) && !isSystemReadyToday) {
if (MarketUtil.canTradeToday()) {
SystemSleepPreventer.wakeDisplay()
shouldShowFullWindow = true
@ -1008,8 +1065,8 @@ enum class InvestmentGrade(
val allocationRate: ConfigIndex,
) {
LEVEL_5_STRONG_RECOMMEND(
displayName = "최상급 추천",
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
displayName = "최상급 스윙/가치형",
description = "중장기 추세가 완벽하며 단기 파동까지 일치하는 매우 안정적인 매수 추천",
shortWeight = 1.0,
midWeight = 1.0,
longWeight = 1.0,
@ -1018,8 +1075,8 @@ enum class InvestmentGrade(
allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE,
),
LEVEL_4_BALANCED_RECOMMEND(
displayName = "균형 추천",
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
displayName = "우량 균형형",
description = "기본적인 펀더멘털과 중장기 추세가 양호하여 꾸준한 우상향이 기대되는 종목",
shortWeight = 0.8,
midWeight = 1.0,
longWeight = 1.0,
@ -1028,8 +1085,8 @@ enum class InvestmentGrade(
allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE,
),
LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천",
description = "기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
displayName = "보수적 혼합형",
description = "장기 지표는 양호하나 단기 변동성이 있거나, 반대로 단기 수급만 몰린 팽팽한 상태",
shortWeight = 0.6,
midWeight = 1.0,
longWeight = 1.0,
@ -1038,8 +1095,8 @@ enum class InvestmentGrade(
allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE,
),
LEVEL_2_HIGH_RISK(
displayName = "고위험 추천",
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
displayName = "고위험 단기 모멘텀",
description = "중장기 추세는 약하지만, 뉴스나 테마로 인해 단기 수급이 강력하게 붙은 스캘핑 대상",
shortWeight = 1.0,
midWeight = 0.4,
longWeight = 0.4,
@ -1048,14 +1105,23 @@ enum class InvestmentGrade(
allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
displayName = "순수 투기/초단타",
description = "재무 및 중장기 지표 무관, 오직 초단기 분봉과 에너지만 살아있는 극도의 투기적 진입",
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2,
profitGuide = ConfigIndex.GRADE_1_PROFIT,
buyGuide = ConfigIndex.GRADE_1_BUY,
allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE,
),
LEVEL_0_SPECULATIVE(
displayName = "매수 금지 (관망)",
description = "최소 신뢰도(Confidence) 미달로 시스템 통과 실패",
shortWeight = 0.1,
midWeight = 0.1,
longWeight = 0.1,
profitGuide = ConfigIndex.GRADE_1_PROFIT, // 더미 데이터
buyGuide = ConfigIndex.GRADE_1_BUY,
allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE,
)
}

View File

@ -35,7 +35,7 @@ fun TradingDecisionLog() {
val coroutineScope = rememberCoroutineScope()
var searchQuery by remember { mutableStateOf("") }
var selectedFilters by remember { mutableStateOf(setOf("전체")) }
val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER")
val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER","NOTICE")
var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) }
LaunchedEffect(AutoTradingManager.llmAnalyser) {
llmAnalyser = AutoTradingManager.llmAnalyser
@ -165,6 +165,7 @@ fun TradingDecisionLog() {
"RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열)
"WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시)
"AFTER" -> Color.Red
"NOTICE" ->Color(0xFF1E88E5)
else -> Color.DarkGray
},
fontWeight = FontWeight.ExtraBold