...
This commit is contained in:
parent
b95c1d5f72
commit
6494784bbc
80
src/main/kotlin/analyzer/AdvancedTradeAssistant.kt
Normal file
80
src/main/kotlin/analyzer/AdvancedTradeAssistant.kt
Normal 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
|
||||
)
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user