...
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()
|
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 {
|
fun calculateScore(fs: FinancialStatement): Int {
|
||||||
var score = 50.0 // 중립 시작
|
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 ultraShort: Int, // 초단기 (분봉/에너지)
|
||||||
val shortTerm: Int, // 단기 (일봉/뉴스)
|
val shortTerm: Int, // 단기 (일봉/뉴스)
|
||||||
val midTerm: Int, // 중기 (주봉/재무)
|
val midTerm: Int, // 중기 (주봉/재무)
|
||||||
val longTerm: Int // 장기 (월봉/펀더멘털)
|
val longTerm: Int // 장기 (월봉/펀더멘털)
|
||||||
) {
|
) {
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return """
|
return """
|
||||||
@ -28,15 +28,16 @@ data class InvestmentScores(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class TechnicalAnalyzer {
|
class TechnicalAnalyzer {
|
||||||
var monthly: List<CandleData> = emptyList()
|
// 주의: min30은 '30분봉'이 아니라 '1분 단위 캔들 30개'를 의미합니다.
|
||||||
var weekly: List<CandleData> = emptyList()
|
|
||||||
var daily: List<CandleData> = emptyList()
|
|
||||||
var min30: List<CandleData> = emptyList()
|
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() }
|
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [신규] 기술적 지표와 추세를 결합한 종합 신호 생성
|
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
|
||||||
*/
|
*/
|
||||||
fun generateComprehensiveSignal(): ScalpingSignalModel {
|
fun generateComprehensiveSignal(): ScalpingSignalModel {
|
||||||
val scalpingAnalyzer = ScalpingAnalyzer()
|
val scalpingAnalyzer = ScalpingAnalyzer()
|
||||||
@ -44,10 +45,9 @@ class TechnicalAnalyzer {
|
|||||||
|
|
||||||
// 1. 기본 스캘핑 신호 생성
|
// 1. 기본 스캘핑 신호 생성
|
||||||
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
|
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
|
||||||
|
|
||||||
// 2. 점수 정교화 (가점/감점 요인)
|
|
||||||
var refinedScore = baseSignal.compositeScore.toDouble()
|
var refinedScore = baseSignal.compositeScore.toDouble()
|
||||||
|
|
||||||
|
// 2. 점수 정교화 (가점/감점 요인)
|
||||||
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
||||||
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
|
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
|
||||||
refinedScore += 10.0
|
refinedScore += 10.0
|
||||||
@ -67,20 +67,64 @@ class TechnicalAnalyzer {
|
|||||||
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
|
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
|
||||||
if (bodyRange > atr * 1.2) refinedScore += 7.0
|
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(
|
return baseSignal.copy(
|
||||||
compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(),
|
compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(),
|
||||||
successProbPct = (refinedScore * 0.85).coerceAtMost(98.0)
|
successProbPct = (refinedScore * 0.85).coerceAtMost(98.0)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직
|
||||||
|
*/
|
||||||
fun isOverheatedStock(): Boolean {
|
fun isOverheatedStock(): Boolean {
|
||||||
if (min30.size < 20 || daily.size < 20) return false
|
if (daily.size < 20) return false
|
||||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
|
// 1. 일봉 20일선 이격도
|
||||||
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||||
val disparityDaily = (currentPrice / ma20Daily) * 100
|
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||||||
|
|
||||||
// 이격도 115% 이상이면 주의, 125% 이상이면 과열
|
// 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인)
|
||||||
return disparityDaily > 115.0
|
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 {
|
fun calculateScores(financialScore100: Int): InvestmentScores {
|
||||||
@ -100,7 +144,7 @@ class TechnicalAnalyzer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 유틸리티 함수군 (기존 로직 유지 및 보완) ---
|
// --- 이하 유틸리티 함수군 (변경 없음) ---
|
||||||
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
|
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
|
||||||
if (candles.size < period + 1) return 0.0
|
if (candles.size < period + 1) return 0.0
|
||||||
val sub = candles.takeLast(period + 1)
|
val sub = candles.takeLast(period + 1)
|
||||||
@ -170,14 +214,14 @@ class TechnicalAnalyzer {
|
|||||||
val signal = generateComprehensiveSignal()
|
val signal = generateComprehensiveSignal()
|
||||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
// [표준화된 분석 점수] - AI에게 가이드라인 제공
|
// [표준화된 분석 점수]
|
||||||
val standardizedScores = """
|
val standardizedScores = """
|
||||||
- Financial Health Score: $finScore100 / 100
|
- Financial Health Score: $finScore100 / 100
|
||||||
- Technical Momentum Score: ${signal.compositeScore} / 100
|
- Technical Momentum Score: ${signal.compositeScore} / 100
|
||||||
- Market Energy (Volume): ${"%.1f".format(signal.volRatio)}x relative to avg
|
- Market Energy (Volume): ${"%.1f".format(signal.volRatio)}x relative to avg
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
// [시계열 가격 흐름] - AI에게 지지와 저항 맥락 제공
|
// [시계열 가격 흐름]
|
||||||
val monthlyRange = monthly.takeLast(3).joinToString(" -> ") {
|
val monthlyRange = monthly.takeLast(3).joinToString(" -> ") {
|
||||||
"[H:${it.stck_hgpr}, L:${it.stck_lwpr}]"
|
"[H:${it.stck_hgpr}, L:${it.stck_lwpr}]"
|
||||||
}
|
}
|
||||||
@ -188,12 +232,10 @@ class TechnicalAnalyzer {
|
|||||||
return """
|
return """
|
||||||
# [Standardized Analysis Summary]
|
# [Standardized Analysis Summary]
|
||||||
$standardizedScores
|
$standardizedScores
|
||||||
|
|
||||||
# [Historical Price Range]
|
# [Historical Price Range]
|
||||||
- Monthly (Last 3M): $monthlyRange
|
- Monthly (Last 3M): $monthlyRange
|
||||||
- Weekly (Last 4W): $weeklyRange
|
- Weekly (Last 4W): $weeklyRange
|
||||||
- Current Price: $currentPrice
|
- Current Price: $currentPrice
|
||||||
|
|
||||||
# [Technical Context]
|
# [Technical Context]
|
||||||
- Base Position: ${"%.1f".format((currentPrice / daily.takeLast(120).map { it.stck_prpr.toDouble() }.average()) * 100)}% (120MA)
|
- Base Position: ${"%.1f".format((currentPrice / daily.takeLast(120).map { it.stck_prpr.toDouble() }.average()) * 100)}% (120MA)
|
||||||
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
|
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
|
||||||
|
|||||||
@ -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) {
|
fun addAfterMarketLog(name : String, code : String, log: String) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
|
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.EMBEDDING_PORT
|
||||||
import Defines.LLM_PORT
|
import Defines.LLM_PORT
|
||||||
import TradingLogStore
|
import TradingLogStore
|
||||||
|
import analyzer.AdvancedTradeAssistant
|
||||||
import analyzer.FinancialAnalyzer
|
import analyzer.FinancialAnalyzer
|
||||||
import analyzer.FinancialMapper
|
import analyzer.FinancialMapper
|
||||||
import analyzer.FinancialStatement
|
import analyzer.FinancialStatement
|
||||||
@ -55,6 +56,7 @@ import java.time.ZonedDateTime
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
//interface TradingAnalyst {
|
//interface TradingAnalyst {
|
||||||
@ -67,6 +69,13 @@ import java.util.concurrent.TimeUnit
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
object RagService {
|
object RagService {
|
||||||
|
val isSafetyBeltStockCodes = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
// (매일 아침 8시 30분 시스템 초기화 시 호출해주어야 함)
|
||||||
|
fun clearDailyCache() {
|
||||||
|
isSafetyBeltStockCodes.clear()
|
||||||
|
println("🧹 [System] 일일 재무 미달 캐시 초기화 완료")
|
||||||
|
}
|
||||||
|
|
||||||
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
|
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
|
||||||
private val embeddingModel = OpenAiEmbeddingModel.builder()
|
private val embeddingModel = OpenAiEmbeddingModel.builder()
|
||||||
@ -237,6 +246,13 @@ object RagService {
|
|||||||
this.currentPrice = currentPrice
|
this.currentPrice = currentPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSafetyBeltStockCodes.contains(stockCode)) {
|
||||||
|
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
|
||||||
|
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
|
||||||
|
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
|
||||||
|
return@coroutineScope
|
||||||
|
}
|
||||||
|
|
||||||
// [1단계] 재무 분석 및 필터링 (가장 빠름)
|
// [1단계] 재무 분석 및 필터링 (가장 빠름)
|
||||||
val finStartTime = System.currentTimeMillis()
|
val finStartTime = System.currentTimeMillis()
|
||||||
val financialData = NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)?.cCode)
|
val financialData = NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)?.cCode)
|
||||||
@ -256,12 +272,22 @@ object RagService {
|
|||||||
if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
||||||
logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime)
|
logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime)
|
||||||
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false)
|
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false)
|
||||||
|
isSafetyBeltStockCodes.add(stockCode)
|
||||||
return@coroutineScope
|
return@coroutineScope
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
|
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
|
||||||
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
|
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
|
return@coroutineScope
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,8 +312,8 @@ object RagService {
|
|||||||
tradingDecision.newsContext = finalSearchResult.matches().distinct() // 중복 제거
|
tradingDecision.newsContext = finalSearchResult.matches().distinct() // 중복 제거
|
||||||
.take(4) // 10개에서 4개로 축소
|
.take(4) // 10개에서 4개로 축소
|
||||||
.joinToString("\n\n") {
|
.joinToString("\n\n") {
|
||||||
it.embedded().text()
|
it.embedded().text()
|
||||||
}
|
}
|
||||||
|
|
||||||
val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision)
|
val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision)
|
||||||
val ragAiDuration = System.currentTimeMillis() - ragStartTime
|
val ragAiDuration = System.currentTimeMillis() - ragStartTime
|
||||||
@ -442,7 +468,7 @@ object RagService {
|
|||||||
// 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간)
|
// 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간)
|
||||||
val newsStartTime = System.currentTimeMillis()
|
val newsStartTime = System.currentTimeMillis()
|
||||||
val (newsScore100, newsReason) = tempDecision.newsContext?.let {
|
val (newsScore100, newsReason) = tempDecision.newsContext?.let {
|
||||||
getAiNewsScore(it, tempDecision.techSummary ?: "")
|
getAiNewsScore(stockName,it, tempDecision.techSummary ?: "")
|
||||||
} ?: (50.0 to "참조 뉴스 없음")
|
} ?: (50.0 to "참조 뉴스 없음")
|
||||||
val newsDuration = System.currentTimeMillis() - newsStartTime
|
val newsDuration = System.currentTimeMillis() - newsStartTime
|
||||||
|
|
||||||
@ -460,14 +486,41 @@ object RagService {
|
|||||||
if (isOverheated) finalConfidence *= 0.85
|
if (isOverheated) finalConfidence *= 0.85
|
||||||
|
|
||||||
val totalScore = (scores.ultraShort + scores.shortTerm + scores.midTerm + scores.longTerm) / 4.0
|
val totalScore = (scores.ultraShort + scores.shortTerm + scores.midTerm + scores.longTerm) / 4.0
|
||||||
val grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence)
|
tempDecision.ultraShortScore = scores.ultraShort.toDouble()
|
||||||
val synthDuration = System.currentTimeMillis() - synthStartTime
|
tempDecision.shortTermScore = scores.shortTerm.toDouble()
|
||||||
|
tempDecision.midTermScore = scores.midTerm.toDouble()
|
||||||
|
tempDecision.longTermScore = scores.longTerm.toDouble()
|
||||||
|
|
||||||
// 5. 최종 결정 및 사유 정리
|
// 5. 최종 결정 및 사유 정리
|
||||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||||
var finalDecision = "HOLD"
|
var finalDecision = "HOLD"
|
||||||
var finalReason = ""
|
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 {
|
when {
|
||||||
newsScore100 < 30.0 -> {
|
newsScore100 < 30.0 -> {
|
||||||
finalDecision = "HOLD"
|
finalDecision = "HOLD"
|
||||||
@ -477,9 +530,9 @@ object RagService {
|
|||||||
finalDecision = "HOLD"
|
finalDecision = "HOLD"
|
||||||
finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한"
|
finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한"
|
||||||
}
|
}
|
||||||
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_1_SPECULATIVE -> {
|
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_0_SPECULATIVE -> {
|
||||||
finalDecision = "BUY"
|
finalDecision = "BUY"
|
||||||
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수"
|
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수 | $assistantReason"
|
||||||
}
|
}
|
||||||
finalConfidence < 40.0 -> {
|
finalConfidence < 40.0 -> {
|
||||||
finalDecision = "SELL"
|
finalDecision = "SELL"
|
||||||
@ -497,6 +550,9 @@ object RagService {
|
|||||||
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
||||||
|
|
||||||
return TradingDecision().apply {
|
return TradingDecision().apply {
|
||||||
|
this.technicalScore = techScore100
|
||||||
|
this.financialScore = finScore100
|
||||||
|
this.systemScore = sysScore100
|
||||||
this.stockCode = tempDecision.stockCode
|
this.stockCode = tempDecision.stockCode
|
||||||
this.stockName = stockName
|
this.stockName = stockName
|
||||||
this.currentPrice = tempDecision.currentPrice
|
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 = """
|
val prompt = """
|
||||||
# Role: Expert Quantitative & Sentiment Analyst
|
# Role: Expert Quantitative & Sentiment Analyst
|
||||||
|
# Target Stock: [$stockName]
|
||||||
|
|
||||||
# Task: Evaluate [News Text] by correlating it with [Market Context].
|
# Task: Evaluate [News Text] by correlating it with [Market Context].
|
||||||
|
|
||||||
# Input 1: [Market Context] (Standardized Scores & Price History)
|
# 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.
|
- If 'Base Position' is near 100% (at 120MA), consider it a 'Safe Entry' for long-term holding.
|
||||||
|
|
||||||
# Constraints:
|
# 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.
|
- Output: Strictly JSON format.
|
||||||
|
|
||||||
# JSON Output:
|
# JSON Output:
|
||||||
@ -608,6 +670,9 @@ class TradingDecision {
|
|||||||
var reason: String? = null
|
var reason: String? = null
|
||||||
var confidence: Double = 0.0
|
var confidence: Double = 0.0
|
||||||
var newsScore : 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 investmentGrade : InvestmentGrade? = null
|
||||||
var techSummary : String? = null
|
var techSummary : String? = null
|
||||||
var newsContext : String? = null
|
var newsContext : String? = null
|
||||||
@ -630,6 +695,26 @@ class TradingDecision {
|
|||||||
midTermScore,
|
midTermScore,
|
||||||
longTermScore).average()
|
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 {
|
override fun toString(): String {
|
||||||
return """
|
return """
|
||||||
$corpName($stockName)
|
$corpName($stockName)
|
||||||
@ -639,12 +724,11 @@ shortTermScore :$shortTermScore
|
|||||||
midTermScore :$midTermScore
|
midTermScore :$midTermScore
|
||||||
longTermScore :$longTermScore
|
longTermScore :$longTermScore
|
||||||
decision: $decision
|
decision: $decision
|
||||||
|
investmentGrade:${investmentGrade!!.name}
|
||||||
reason: $reason
|
reason: $reason
|
||||||
confidence: $confidence
|
confidence: $confidence
|
||||||
기술 분석: $techSummary
|
기술 분석: $techSummary
|
||||||
|
|
||||||
뉴스 점수: $newsScore
|
뉴스 점수: $newsScore
|
||||||
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import Defines.EMBEDDING_PORT
|
|||||||
import Defines.LLM_PORT
|
import Defines.LLM_PORT
|
||||||
import network.TradingDecision
|
import network.TradingDecision
|
||||||
import TradingLogStore
|
import TradingLogStore
|
||||||
|
import analyzer.AdvancedTradeAssistant
|
||||||
import analyzer.TechnicalAnalyzer
|
import analyzer.TechnicalAnalyzer
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import getLlamaBinPath
|
import getLlamaBinPath
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -25,31 +25,27 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import model.CandleData
|
|
||||||
import model.ConfigIndex
|
import model.ConfigIndex
|
||||||
import model.ExecutionData
|
import model.ExecutionData
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import model.RankingStock
|
import model.RankingStock
|
||||||
import model.RankingType
|
import model.RankingType
|
||||||
import model.UnifiedBalance
|
import model.UnifiedBalance
|
||||||
|
import model.UnifiedStockHolding
|
||||||
import network.DartCodeManager
|
import network.DartCodeManager
|
||||||
import network.KisAuthService
|
import network.KisAuthService
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
import network.KisWebSocketManager
|
import network.KisWebSocketManager
|
||||||
import network.RagService
|
import network.RagService
|
||||||
import network.StockUniverseLoader
|
import network.StockUniverseLoader
|
||||||
import org.jetbrains.skia.ImageFilter
|
|
||||||
import util.MarketUtil
|
import util.MarketUtil
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.collections.List
|
import kotlin.collections.List
|
||||||
|
|
||||||
import kotlin.math.*
|
|
||||||
// service/AutoTradingManager.kt
|
// service/AutoTradingManager.kt
|
||||||
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
|
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
|
||||||
object AutoTradingManager {
|
object AutoTradingManager {
|
||||||
@ -88,7 +84,7 @@ object AutoTradingManager {
|
|||||||
val nowDate = LocalDate.now(seoulZone)
|
val nowDate = LocalDate.now(seoulZone)
|
||||||
var checkTime = 60_000 * 3L
|
var checkTime = 60_000 * 3L
|
||||||
val isTradingDay = nowDate.dayOfWeek.value in 1..5
|
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
|
shouldShowFullWindow = true
|
||||||
SystemSleepPreventer.wakeDisplay()
|
SystemSleepPreventer.wakeDisplay()
|
||||||
} else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) {
|
} 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) {
|
// if (isSuccess && completeTradingDecision != null) {
|
||||||
// // 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
|
// // 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
|
||||||
// TradingLogStore.addLog(completeTradingDecision)
|
// TradingLogStore.addLog(completeTradingDecision)
|
||||||
@ -188,79 +184,91 @@ object AutoTradingManager {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
|
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
|
||||||
if (isSuccess && completeTradingDecision != null) {
|
if (isSuccess && completeTradingDecision != null) {
|
||||||
val decision = completeTradingDecision
|
val decision = completeTradingDecision
|
||||||
|
|
||||||
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
|
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
|
||||||
if (decision.decision == "BUY") {
|
if (decision.decision == "BUY") {
|
||||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||||
|
|
||||||
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
|
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
|
||||||
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
|
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||||
|
|
||||||
// 2. 최종 매수 실행
|
// 2. 최종 매수 실행
|
||||||
val gradeRate = KisSession.config.getValues(grade.allocationRate)
|
val gradeRate = KisSession.config.getValues(grade.allocationRate)
|
||||||
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
|
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
|
||||||
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
|
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
|
||||||
|
TradingLogStore.addLog(decision,"BUY",decision.summary())
|
||||||
excuteTrade(
|
excuteTrade(
|
||||||
decision = decision,
|
decision = decision,
|
||||||
orderQty = calculatedQty.toString(),
|
orderQty = calculatedQty.toString(),
|
||||||
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
|
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
|
||||||
investmentGrade = grade
|
investmentGrade = grade
|
||||||
)
|
)
|
||||||
} else if (decision.confidence >= 60.0) { // 아까운 종목만 재분석
|
} else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석
|
||||||
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
|
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val MIN_CONFIDENCE = 60.0 // 최소 신뢰도
|
|
||||||
var append = 0.0
|
|
||||||
|
|
||||||
fun getInvestmentGrade(
|
fun getInvestmentGrade(
|
||||||
ts: TradingDecision,
|
ts: TradingDecision,
|
||||||
totalScore: Double,
|
totalScore: Double,
|
||||||
confidence: Double
|
confidence: Double,
|
||||||
|
finScore100: Double // 💡 [수정1] 컴파일 에러 방지용 파라미터 추가
|
||||||
): InvestmentGrade {
|
): InvestmentGrade {
|
||||||
// [개선] 하드코딩된 60/70 대신 사용자 설정 최소 점수를 기준으로 사용
|
|
||||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||||
val minConfidence = minScore // 신뢰도 하한선도 매수 기준 점수와 동기화
|
val minConfidence = minScore
|
||||||
|
|
||||||
// 1. 최소 기준 미달 시 (관망 대상)
|
|
||||||
if (totalScore < (minScore * 0.8) || confidence < minConfidence) {
|
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 shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0
|
||||||
val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0
|
val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0
|
||||||
val isOverheated = ts.analyzer?.isOverheatedStock() ?: true
|
val isOverheated = ts.analyzer?.isOverheatedStock() ?: true
|
||||||
|
|
||||||
// 3. [개선] 점수 구간을 5~10점씩 하향 조정하여 실제 '추천' 등급이 나오도록 보정
|
// 1. 기본 등급 산정
|
||||||
val rawGrade = when {
|
var rawGrade = when {
|
||||||
// [A그룹] 중장기 추세가 강한 상태
|
midLongAvg >= 70.0 -> {
|
||||||
midLongAvg >= 70.0 -> { // 75 -> 70 하향
|
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
|
||||||
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // 80 -> 75
|
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
||||||
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 70 -> 65
|
|
||||||
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||||
}
|
}
|
||||||
|
midLongAvg >= 60.0 -> {
|
||||||
// [B그룹] 중장기 추세가 보통인 상태
|
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
||||||
midLongAvg >= 60.0 -> { // 65 -> 60 하향
|
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||||
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 75 -> 70
|
|
||||||
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // 65 -> 60
|
|
||||||
else InvestmentGrade.LEVEL_2_HIGH_RISK
|
else InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||||
}
|
}
|
||||||
|
|
||||||
// [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태
|
|
||||||
else -> {
|
else -> {
|
||||||
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
|
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||||
else InvestmentGrade.LEVEL_1_SPECULATIVE
|
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) {
|
return if (isOverheated) {
|
||||||
when (rawGrade) {
|
when (rawGrade) {
|
||||||
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
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 {
|
scope.launch {
|
||||||
var basePrice = decision.currentPrice
|
var basePrice = decision.currentPrice
|
||||||
val tickSize = MarketUtil.getTickSize(basePrice)
|
val tickSize = MarketUtil.getTickSize(basePrice)
|
||||||
|
// 등급별 가이드에 따라 매수 호가 설정
|
||||||
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
|
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
|
||||||
var stockCode = decision.stockCode
|
var stockCode = decision.stockCode
|
||||||
var stockName = decision.stockName
|
var stockName = decision.stockName
|
||||||
@ -284,15 +293,16 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
|
|
||||||
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
|
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
|
||||||
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
|
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
|
||||||
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
.onSuccess { realOrderNo ->
|
||||||
println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice")
|
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
|
||||||
TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo")
|
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
|
||||||
val pRate = 0.4
|
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
|
||||||
val sRate = -1.5
|
|
||||||
|
|
||||||
|
// 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장)
|
||||||
|
val sRate = -1.5
|
||||||
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
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 calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
|
||||||
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
||||||
@ -303,7 +313,7 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
code = stockCode,
|
code = stockCode,
|
||||||
name = stockName,
|
name = stockName,
|
||||||
quantity = inputQty,
|
quantity = inputQty,
|
||||||
profitRate = effectiveProfitRate, // 보정된 수익률 저장
|
profitRate = effectiveProfitRate,
|
||||||
stopLossRate = sRate,
|
stopLossRate = sRate,
|
||||||
targetPrice = calculatedTarget,
|
targetPrice = calculatedTarget,
|
||||||
stopLossPrice = calculatedStop,
|
stopLossPrice = calculatedStop,
|
||||||
@ -311,7 +321,9 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
isDomestic = true
|
isDomestic = true
|
||||||
))
|
))
|
||||||
syncAndExecute(realOrderNo)
|
syncAndExecute(realOrderNo)
|
||||||
TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
|
|
||||||
|
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
|
||||||
|
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
|
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
|
||||||
@ -347,16 +359,13 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
|
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
|
||||||
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
|
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. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
|
||||||
|
|
||||||
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
|
|
||||||
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
|
|
||||||
|
|
||||||
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
|
|
||||||
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
||||||
|
|
||||||
|
|
||||||
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
|
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
|
||||||
|
|
||||||
KisTradeService.postOrder(
|
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) {
|
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
|
||||||
var targetPrice = holding.currentPrice.toDouble()
|
var targetPrice = holding.currentPrice.toDouble()
|
||||||
TradingLogStore.addAfterMarketLog(
|
TradingLogStore.addAfterMarketLog(
|
||||||
holding.name,
|
holding.name,
|
||||||
holding.code,
|
holding.code,
|
||||||
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
|
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
|
||||||
)
|
)
|
||||||
|
|
||||||
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
|
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
|
||||||
|
|
||||||
@ -452,6 +461,8 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
"🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
|
"🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
analyzeDeepLossHoldingsAfterMarket(holding)
|
||||||
}
|
}
|
||||||
delay(300) // API 호출 부하 방지
|
delay(300) // API 호출 부하 방지
|
||||||
}
|
}
|
||||||
@ -460,68 +471,113 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
|
|
||||||
|
|
||||||
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
|
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
|
||||||
// if (isRunning()) return
|
|
||||||
val now = LocalTime.now()
|
val now = LocalTime.now()
|
||||||
val currentMinute = now.minute
|
val currentMinute = now.minute
|
||||||
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
|
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
|
||||||
println("resumePendingSellOrders")
|
println("resumePendingSellOrders")
|
||||||
balance.holdings.forEach { holding ->
|
balance.holdings.forEach { holding ->
|
||||||
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
|
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
|
||||||
println("❌ 차단 처리된 주식 : ${holding.name}")
|
println("❌ 차단 처리된 주식 : ${holding.name}")
|
||||||
TradingLogStore.addAnalyzer(
|
TradingLogStore.addAnalyzer(
|
||||||
holding.name,
|
holding.name,
|
||||||
holding.code,
|
holding.code,
|
||||||
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
|
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
|
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} ")
|
var targetPrice = holding.currentPrice.toDouble()
|
||||||
|
val now = LocalTime.now()
|
||||||
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
|
val currentMinute = now.minute
|
||||||
var targetPrice = holding.currentPrice.toDouble()
|
var isBefore930 = false
|
||||||
val now = LocalTime.now()
|
if (now.hour == 9 && currentMinute < 30) {
|
||||||
val currentMinute = now.minute
|
targetPrice = targetPrice
|
||||||
var isBefore930 = false
|
isBefore930 = true
|
||||||
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}] "
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
TradingLogStore.addAnalyzer(
|
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
|
||||||
"보유주식[${holding.name}]",
|
}
|
||||||
|
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.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 isSystemReadyToday = false
|
||||||
var isSystemCleanedUpToday = false
|
var isSystemCleanedUpToday = false
|
||||||
private var lastRetryTime = 0L
|
private var lastRetryTime = 0L
|
||||||
@ -567,9 +623,9 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
var waitTime = 0.2
|
var waitTime = 0.2
|
||||||
val H16 = LocalTime.of(16, 0)
|
val H16 = LocalTime.of(16, 0)
|
||||||
val H18 = LocalTime.of(18, 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 H08M45 = LocalTime.of(8, 45)
|
||||||
val H08M30 = LocalTime.of(7, 50)
|
val H07M50 = LocalTime.of(7, 50)
|
||||||
private fun runDiscoveryLoop(callback: TradingDecisionCallback) {
|
private fun runDiscoveryLoop(callback: TradingDecisionCallback) {
|
||||||
discoveryJob = scope.launch {
|
discoveryJob = scope.launch {
|
||||||
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
|
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
|
||||||
@ -579,10 +635,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
currentTimeMillis = System.currentTimeMillis()
|
currentTimeMillis = System.currentTimeMillis()
|
||||||
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
|
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
|
||||||
when {
|
when {
|
||||||
now.isAfter(H18) || now.isBefore(H08M35) -> {
|
now.isAfter(H18) || now.isBefore(H08M00) -> {
|
||||||
prepareMarketOpen(now)
|
prepareMarketOpen(now)
|
||||||
}
|
}
|
||||||
now.isBefore(H18) && now.isAfter(H08M35) -> {
|
now.isBefore(H18) && now.isAfter(H08M00) -> {
|
||||||
waitTime = 0.2
|
waitTime = 0.2
|
||||||
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
|
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
|
||||||
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
|
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
|
||||||
@ -624,9 +680,10 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun prepareMarketOpen(now : LocalTime) {
|
suspend fun prepareMarketOpen(now : LocalTime) {
|
||||||
if (now.isAfter(H18) || now.isBefore(H08M30)) {
|
if (now.isAfter(H18) || now.isBefore(H07M50)) {
|
||||||
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
|
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
|
||||||
onMarketClosed?.invoke()
|
onMarketClosed?.invoke()
|
||||||
|
RagService.clearDailyCache()
|
||||||
KisWebSocketManager.disconnect()
|
KisWebSocketManager.disconnect()
|
||||||
BrowserManager.closeIfIdle(0)
|
BrowserManager.closeIfIdle(0)
|
||||||
LlamaServerManager.stopAll() // AI 서버 완전 종료
|
LlamaServerManager.stopAll() // AI 서버 완전 종료
|
||||||
@ -634,7 +691,7 @@ val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boo
|
|||||||
isSystemReadyToday = false
|
isSystemReadyToday = false
|
||||||
shouldShowFullWindow = false
|
shouldShowFullWindow = false
|
||||||
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
|
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
|
||||||
} else if (now.isAfter(H08M30) && now.isBefore(H08M35) && !isSystemReadyToday) {
|
} else if (now.isAfter(H07M50) && now.isBefore(H08M00) && !isSystemReadyToday) {
|
||||||
if (MarketUtil.canTradeToday()) {
|
if (MarketUtil.canTradeToday()) {
|
||||||
SystemSleepPreventer.wakeDisplay()
|
SystemSleepPreventer.wakeDisplay()
|
||||||
shouldShowFullWindow = true
|
shouldShowFullWindow = true
|
||||||
@ -1008,8 +1065,8 @@ enum class InvestmentGrade(
|
|||||||
val allocationRate: ConfigIndex,
|
val allocationRate: ConfigIndex,
|
||||||
) {
|
) {
|
||||||
LEVEL_5_STRONG_RECOMMEND(
|
LEVEL_5_STRONG_RECOMMEND(
|
||||||
displayName = "최상급 추천",
|
displayName = "최상급 스윙/가치형",
|
||||||
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
|
description = "중장기 추세가 완벽하며 단기 파동까지 일치하는 매우 안정적인 매수 추천",
|
||||||
shortWeight = 1.0,
|
shortWeight = 1.0,
|
||||||
midWeight = 1.0,
|
midWeight = 1.0,
|
||||||
longWeight = 1.0,
|
longWeight = 1.0,
|
||||||
@ -1018,8 +1075,8 @@ enum class InvestmentGrade(
|
|||||||
allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE,
|
allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE,
|
||||||
),
|
),
|
||||||
LEVEL_4_BALANCED_RECOMMEND(
|
LEVEL_4_BALANCED_RECOMMEND(
|
||||||
displayName = "균형 추천",
|
displayName = "우량 균형형",
|
||||||
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
|
description = "기본적인 펀더멘털과 중장기 추세가 양호하여 꾸준한 우상향이 기대되는 종목",
|
||||||
shortWeight = 0.8,
|
shortWeight = 0.8,
|
||||||
midWeight = 1.0,
|
midWeight = 1.0,
|
||||||
longWeight = 1.0,
|
longWeight = 1.0,
|
||||||
@ -1028,8 +1085,8 @@ enum class InvestmentGrade(
|
|||||||
allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE,
|
allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE,
|
||||||
),
|
),
|
||||||
LEVEL_3_CAUTIOUS_RECOMMEND(
|
LEVEL_3_CAUTIOUS_RECOMMEND(
|
||||||
displayName = "보수적 추천",
|
displayName = "보수적 혼합형",
|
||||||
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
|
description = "중장기 지표는 양호하나 단기 변동성이 있거나, 반대로 단기 수급만 몰린 팽팽한 상태",
|
||||||
shortWeight = 0.6,
|
shortWeight = 0.6,
|
||||||
midWeight = 1.0,
|
midWeight = 1.0,
|
||||||
longWeight = 1.0,
|
longWeight = 1.0,
|
||||||
@ -1038,8 +1095,8 @@ enum class InvestmentGrade(
|
|||||||
allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE,
|
allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE,
|
||||||
),
|
),
|
||||||
LEVEL_2_HIGH_RISK(
|
LEVEL_2_HIGH_RISK(
|
||||||
displayName = "고위험 추천",
|
displayName = "고위험 단기 모멘텀",
|
||||||
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
|
description = "중장기 추세는 약하지만, 뉴스나 테마로 인해 단기 수급이 강력하게 붙은 스캘핑 대상",
|
||||||
shortWeight = 1.0,
|
shortWeight = 1.0,
|
||||||
midWeight = 0.4,
|
midWeight = 0.4,
|
||||||
longWeight = 0.4,
|
longWeight = 0.4,
|
||||||
@ -1048,14 +1105,23 @@ enum class InvestmentGrade(
|
|||||||
allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE,
|
allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE,
|
||||||
),
|
),
|
||||||
LEVEL_1_SPECULATIVE(
|
LEVEL_1_SPECULATIVE(
|
||||||
displayName = "순수 공격적 선택",
|
displayName = "순수 투기/초단타",
|
||||||
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
|
description = "재무 및 중장기 지표 무관, 오직 초단기 분봉과 에너지만 살아있는 극도의 투기적 진입",
|
||||||
shortWeight = 1.0,
|
shortWeight = 1.0,
|
||||||
midWeight = 0.2,
|
midWeight = 0.2,
|
||||||
longWeight = 0.2,
|
longWeight = 0.2,
|
||||||
profitGuide = ConfigIndex.GRADE_1_PROFIT,
|
profitGuide = ConfigIndex.GRADE_1_PROFIT,
|
||||||
buyGuide = ConfigIndex.GRADE_1_BUY,
|
buyGuide = ConfigIndex.GRADE_1_BUY,
|
||||||
allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE,
|
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()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
var selectedFilters by remember { mutableStateOf(setOf("전체")) }
|
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) }
|
var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) }
|
||||||
LaunchedEffect(AutoTradingManager.llmAnalyser) {
|
LaunchedEffect(AutoTradingManager.llmAnalyser) {
|
||||||
llmAnalyser = AutoTradingManager.llmAnalyser
|
llmAnalyser = AutoTradingManager.llmAnalyser
|
||||||
@ -165,6 +165,7 @@ fun TradingDecisionLog() {
|
|||||||
"RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열)
|
"RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열)
|
||||||
"WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시)
|
"WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시)
|
||||||
"AFTER" -> Color.Red
|
"AFTER" -> Color.Red
|
||||||
|
"NOTICE" ->Color(0xFF1E88E5)
|
||||||
else -> Color.DarkGray
|
else -> Color.DarkGray
|
||||||
},
|
},
|
||||||
fontWeight = FontWeight.ExtraBold
|
fontWeight = FontWeight.ExtraBold
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user