diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 5ea1bf1..b56cea3 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -61,7 +61,6 @@ import service.AutoTradingManager import service.AutoTradingManager.isSystemCleanedUpToday import service.SystemSleepPreventer import service.TradingDecisionCallback -import ui.DashboardScreen import ui.SettingsScreen import ui.TradingDecisionLog import util.PortFinder @@ -262,7 +261,7 @@ fun main() = application { ) } AppScreen.Dashboard -> { - DashboardScreen() +// DashboardScreen() } AppScreen.TradingDecision -> { TradingDecisionLog() diff --git a/src/main/kotlin/analyzer/FinancialAnalyzer.kt b/src/main/kotlin/analyzer/FinancialAnalyzer.kt new file mode 100644 index 0000000..251bbf5 --- /dev/null +++ b/src/main/kotlin/analyzer/FinancialAnalyzer.kt @@ -0,0 +1,206 @@ +package analyzer + +import kotlin.compareTo + + +object FinancialAnalyzer { + + /** + * [매수 고려] 우량 기업 요건 확인 + * 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다. + */ + fun isSafetyBeltMet(fs: FinancialStatement): Boolean { + // 1. 유동성 위기 체크 (이건 유지하는 것이 좋습니다) + val isDebtSafe = fs.debtRatio < 300.0 // 200% -> 300%로 완화 + val isLiquiditySafe = fs.quickRatio > 60.0 // 80% -> 60%로 완화 (급전이 필요한 수준만 차단) + + // 2. 턴어라운드 허용 (적자여도 개선 중이면 통과) + val isTurningAround = !fs.isNetIncomePositive && fs.operatingProfitGrowth > 50.0 + val isNotFatalDeficit = fs.isNetIncomePositive || isTurningAround + + // 3. 상장폐지 요건 중심 필터 + val isNotCapitalImpaired = fs.capitalImpairmentRate < 50.0 // 자본잠식 50% 이상 차단 + val isNotLossExploding = fs.lossToSalesRatio < 150.0 // 매출보다 손실이 너무 크면 차단 + + // 최종: 정말 위험한 경우가 아니면 분석 단계(AI/뉴스)로 보냄 + return isDebtSafe && isLiquiditySafe && isNotFatalDeficit && isNotCapitalImpaired + } + + 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() + } + + fun calculateScore(fs: FinancialStatement): Int { + var score = 50.0 // 중립 시작 + + // 1. 수익성 및 성장 추세 (Max 50) + score += when { + fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 20.0 -> 30.0 // 우량 성장 + fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -30.0 -> -20.0 // 쇠퇴 위험 + !fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 50.0 -> 20.0 // 턴어라운드 신호 + !fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -20.0 -> -30.0 // 적자 심화 + else -> 0.0 + } + + // 2. 수익 효율 ROE (Max 30) + score += (fs.roe.coerceIn(-20.0, 20.0) * 1.5) + + // 3. 안정성 (Max 20) + if (fs.debtRatio <= 100.0) score += 20.0 + else if (fs.debtRatio <= 150.0) score += 10.0 + + // 4. 감점 페널티 (위험 징후 시 최대 -50) + if (fs.capitalImpairmentRate > 20.0) score -= 30.0 + if (fs.debtAccelerationRate > 100.0) score -= 20.0 + + return score.coerceIn(0.0, 100.0).toInt() + } + + /** + * 상황별 상태 메시지 정교화 + */ + fun getInvestmentStatus(fs: FinancialStatement): String { + return when { + fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 10.0 -> "🚀 [성장중] 실적 개선세 뚜렷" + !fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 40.0 -> "☀️ [회복중] 적자폭 급감, 턴어라운드 가시화" + fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -40.0 -> "⚠️ [쇠퇴중] 이익 급감, 적자전환 유의" + else -> "🚨 [부실] 재무 구조 악화 지속" + } + } +} + + +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() + } +} diff --git a/src/main/kotlin/analyzer/FinancialMapper.kt b/src/main/kotlin/analyzer/FinancialMapper.kt new file mode 100644 index 0000000..c472fc3 --- /dev/null +++ b/src/main/kotlin/analyzer/FinancialMapper.kt @@ -0,0 +1,75 @@ +package analyzer + +import kotlinx.serialization.Serializable +import kotlin.Double +import kotlin.math.abs + + +@Serializable +data class FinancialStatement( + val revenueGrowth: Double = 0.0, // 매출액 증가율 + val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율 + val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율 + val roe: Double = 0.0, // ROE + val debtRatio: Double = 0.0, // 부채비율 + val quickRatio: Double = 0.0, // 당좌비율 + val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부 + val isNetIncomePositive: Boolean = false, + val capitalImpairmentRate: Double = 0.0, + val debtAccelerationRate: Double = 0.0, + val lossToSalesRatio: Double = 0.0 +) + +object FinancialMapper { + fun mapRawTextToStatement(rawText: String): FinancialStatement { + if (rawText.isBlank()) return FinancialStatement() + + val current = extractYearlyValues(rawText, "당기") + val previous = extractYearlyValues(rawText, "전기") + + // 기본 수치 추출 + val opCurrent = current["영업이익"] ?: 0.0 + val opPrevious = previous["영업이익"] ?: 0.0 + val salesCurrent = current["매출액"] ?: 1.0 + val niCurrent = current["당기순이익(손실)"] ?: 0.0 + val equityCurrent = current["자본총계"] ?: 1.0 + val capitalStock = current["자본금"] ?: 1.0 + val debtCurrent = current["부채총계"] ?: 0.0 + val debtPrevious = previous["부채총계"] ?: 1.0 + val currentAssets = current["유동자산"] ?: 0.0 + val currentLiabilities = current["유동부채"] ?: 1.0 + + // [강화] 자본잠식률: (자본금 - 자본총계) / 자본금 + val capitalImpairment = (capitalStock - equityCurrent) / capitalStock * 100 + + // [강화] 부채 가속도: 전년 대비 부채 증가율 + val debtAcceleration = ((debtCurrent - debtPrevious) / debtPrevious) * 100 + + // [강화] 매출 대비 영업손실률 + val lossToSalesRatio = if (opCurrent < 0) (abs(opCurrent) / salesCurrent) * 100 else 0.0 + + return FinancialStatement( + operatingProfitGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / abs(opPrevious)) * 100 else 0.0, + roe = (niCurrent / equityCurrent) * 100, + debtRatio = (debtCurrent / equityCurrent) * 100, + quickRatio = (currentAssets / currentLiabilities) * 100, + isOperatingProfitPositive = opCurrent > 0, + isNetIncomePositive = niCurrent > 0, + // 추가된 정교화 지표 + capitalImpairmentRate = capitalImpairment, + debtAccelerationRate = debtAcceleration, + lossToSalesRatio = lossToSalesRatio + ) + } + + private fun extractYearlyValues(text: String, type: String): Map { + val result = mutableMapOf() + val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""") + regex.findAll(text).forEach { match -> + val key = match.groupValues[1].trim() + val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0 + result[key] = rawValue + } + return result + } +} \ No newline at end of file diff --git a/src/main/kotlin/analyzer/ScalpingAnalyzer.kt b/src/main/kotlin/analyzer/ScalpingAnalyzer.kt new file mode 100644 index 0000000..408cd84 --- /dev/null +++ b/src/main/kotlin/analyzer/ScalpingAnalyzer.kt @@ -0,0 +1,173 @@ +package analyzer + +import model.CandleData +import service.Candle +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.* + +data class ScalpingSignalModel( + val currentPrice: Double, + val buySignal: Boolean, + val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수) + val successProbPct: Double, // 성공 확률 추정 % + val riskLevel: String, // "Low", "Medium", "High" + val rsi: Double, + val volRatio: Double, + val suggestedSlPrice: Double, // 손절 가격 + val suggestedTpPrice: Double, // 익절 가격 + val riskRewardRatio: Double +) + +class ScalpingAnalyzer { + companion object { + private const val SMA_SHORT = 10 // 단기 이평선 (10봉) + private const val SMA_LONG = 20 // 장기 이평선 (20봉) + private const val RSI_WINDOW = 14 // RSI 기간 + private const val VOL_WINDOW = 20 // 거래량 평균 기간 + private const val VOL_SURGE_THRESHOLD = 1.5 // 평소 대비 1.5배 거래량 + private const val RSI_THRESHOLD = 50.0 // RSI 매수 우위 기준 + private const val DEFAULT_SL_PCT = -1.5 // 기본 손절 라인 (-1.5%) + private const val DEFAULT_TP_PCT = 1.5 // 기본 익절 라인 (+1.5%) + } + + /** + * 실시간 분봉 데이터를 분석하여 종합 신호 모델을 반환합니다. + */ + fun analyze(candles: List, isDailyBullish: Boolean): ScalpingSignalModel { + if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 이상의 데이터가 필요합니다.") + + val closes = candles.map { it.close } + val volumes = candles.map { it.volume } + + // 1. 보조 지표 계산 + val sma10 = simpleMovingAverage(closes, SMA_SHORT) + val sma20 = simpleMovingAverage(closes, SMA_LONG) + val rsiList = computeRSI(closes) + val volAvg = simpleMovingAverage(volumes, VOL_WINDOW) + val (bbUpper, _, bbLower) = bollingerBands(closes) + + // 2. 현재 시점 데이터 추출 + val current = candles.last() + val currentClose = current.close + val sma10Now = sma10.lastOrNull() ?: 0.0 + val sma20Now = sma20.lastOrNull() ?: 0.0 + val rsiNow = rsiList.lastOrNull() ?: 0.0 + val volRatioNow = if (volAvg.isNotEmpty()) current.volume / volAvg.last() else 1.0 + + // 3. 정교화된 상태 판별 로직 + // [볼린저 밴드 위치] 0.0(하단) ~ 1.0(상단) + val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) { + (currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last()) + } else 0.5 + + // [전고점 돌파 확인] 최근 6봉 이내의 최고점을 거래량과 함께 돌파하는지 확인 + val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high } + val isBreakout = currentClose > nearHigh && volRatioNow > 2.0 // 거래량 2배 동반 돌파 + + // [일봉 이격도 과열 체크] 5일 이동평균선 대비 이격도 계산 (상위 호출부에서 계산 권장) + // 여기서는 기술적 지표 조합으로만 판단 + val ma5 = if (candles.size >= 5) candles.takeLast(5).map { it.close }.average() else currentClose + val isOverheated = (currentClose / ma5) * 100 > 112.0 // 12% 이상 이격 시 과열 + + // 4. 매수 신호 확정 조건 + val maBull = currentClose > sma10Now && sma10Now > sma20Now // 정배열 + val rsiBull = rsiNow > RSI_THRESHOLD // 매수 강도 + val volSurge = volRatioNow > VOL_SURGE_THRESHOLD // 수급 폭발 + val bbValid = bbPos in 0.2..0.9 // 밴드 내 안정적 위치 + + val buySignal = maBull && rsiBull && volSurge && isBreakout && !isOverheated && isDailyBullish + + // 5. 종합 점수(0~100) 산출 가중치 + val score = (if (maBull) 25 else 0) + + (if (rsiBull) 15 else 0) + + (if (isBreakout) 20 else 0) + + (minOf((volRatioNow - 1.0) * 15, 20.0)).toInt() + + (if (bbValid) 10 else 0) + + (if (isDailyBullish) 10 else 0) + + // 6. 성공 확률 및 위험도 계산 + val successProb = if (buySignal) 75.0 + (score / 10.0) else 30.0 + (score / 2.0) + + return ScalpingSignalModel( + currentPrice = currentClose, + buySignal = buySignal, + compositeScore = score.coerceIn(0, 100), + successProbPct = successProb.coerceAtMost(98.0), + riskLevel = if (volRatioNow > 5.0) "High" else "Medium", + rsi = rsiNow, + volRatio = volRatioNow, + suggestedSlPrice = currentClose * (1 + DEFAULT_SL_PCT / 100), + suggestedTpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100), + riskRewardRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT) + ) + } + + // --- 내부 계산 유틸리티 --- + private fun simpleMovingAverage(values: List, window: Int): List { + return values.windowed(window).map { it.average() } + } + + private fun computeRSI(closes: List, window: Int = RSI_WINDOW): List { + val rsi = mutableListOf() + if (closes.size < window + 1) return rsi + val changes = closes.zipWithNext { a, b -> b - a } + for (i in window..changes.size) { + val windowChanges = changes.subList(i - window, i) + val gains = windowChanges.filter { it > 0 }.sum() + val losses = windowChanges.filter { it < 0 }.map { abs(it) }.sum() + val rs = if (losses > 0) gains / losses else Double.POSITIVE_INFINITY + rsi.add(100.0 - (100.0 / (1.0 + rs))) + } + return rsi + } + + private fun bollingerBands(closes: List, window: Int = SMA_LONG): Triple, List, List> { + val upper = mutableListOf() + val sma = mutableListOf() + val lower = mutableListOf() + for (i in window..closes.size) { + val slice = closes.subList(i - window, i) + val mean = slice.average() + val std = sqrt(slice.map { (it - mean).pow(2) }.average()) + sma.add(mean) + upper.add(mean + (std * 2)) + lower.add(mean - (std * 2)) + } + return Triple(upper, sma, lower) + } +} + + +fun CandleData.toScalpingCandle(): Candle { + // 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합 + val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}" + val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + + // 2. 타임스탬프(Epoch Milliseconds) 계산 + val timestamp = try { + val ldt = LocalDateTime.parse(dateTimeStr, formatter) + ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + } catch (e: Exception) { + // 시간 파싱 실패 시 현재 시스템 시간 사용 + System.currentTimeMillis() + } + + // 3. String 필드들을 Double로 변환하여 Candle 객체 생성 + return Candle( + timestamp = timestamp, + open = this.stck_oprc.toDoubleOrNull() ?: 0.0, + high = this.stck_hgpr.toDoubleOrNull() ?: 0.0, + low = this.stck_lwpr.toDoubleOrNull() ?: 0.0, + close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할 + volume = this.cntg_vol.toDoubleOrNull() ?: 0.0 + ) +} + +/** + * 리스트 전체를 변환하는 유틸리티 + */ +fun List.toScalpingList(): List { + return this.map { it.toScalpingCandle() } +} diff --git a/src/main/kotlin/analyzer/TechnicalAnalyzer.kt b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt new file mode 100644 index 0000000..f58d688 --- /dev/null +++ b/src/main/kotlin/analyzer/TechnicalAnalyzer.kt @@ -0,0 +1,202 @@ +package analyzer + +import kotlinx.serialization.Serializable +import model.CandleData +import kotlin.math.abs +import kotlin.text.toDouble +import kotlin.text.toInt + + + +data class InvestmentScores( + val ultraShort: Int, // 초단기 (분봉/에너지) + val shortTerm: Int, // 단기 (일봉/뉴스) + val midTerm: Int, // 중기 (주봉/재무) + val longTerm: Int // 장기 (월봉/펀더멘털) +) { + override fun toString(): String { + return """ + 평점 : ${avg()} + 초단 : $ultraShort + 단기 : $shortTerm + 중기 : $midTerm + 장기 : $longTerm + """.trimIndent() + } + fun avg() = listOf(ultraShort, shortTerm, midTerm, longTerm).average() +} + +@Serializable +class TechnicalAnalyzer { + var monthly: List = emptyList() + var weekly: List = emptyList() + var daily: List = emptyList() + var min30: List = emptyList() + + fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() } + + /** + * [신규] 기술적 지표와 추세를 결합한 종합 신호 생성 + */ + fun generateComprehensiveSignal(): ScalpingSignalModel { + val scalpingAnalyzer = ScalpingAnalyzer() + val dailyBullish = isDailyBullish() + + // 1. 기본 스캘핑 신호 생성 + val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish) + + // 2. 점수 정교화 (가점/감점 요인) + var refinedScore = baseSignal.compositeScore.toDouble() + + // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 + if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) { + refinedScore += 10.0 + } + + // [보완] 자금 유입 강도(MFI) 반영 + val mfi = calculateMFI(min30) + when { + mfi > 80.0 -> refinedScore -= 15.0 // 과매수 권역 감점 + mfi < 20.0 -> refinedScore -= 5.0 // 자금 유출 감점 + mfi in 45.0..65.0 -> refinedScore += 5.0 // 안정적 수급 구간 + } + + // [보완] 변동성 돌파 확인 (ATR 대비 현재 몸통 크기) + val atr = calculateATR(min30) + val lastCandle = min30.last() + val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble()) + if (bodyRange > atr * 1.2) refinedScore += 7.0 + + return baseSignal.copy( + compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(), + successProbPct = (refinedScore * 0.85).coerceAtMost(98.0) + ) + } + + fun isOverheatedStock(): Boolean { + if (min30.size < 20 || daily.size < 20) return false + val currentPrice = min30.last().stck_prpr.toDouble() + val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() + val disparityDaily = (currentPrice / ma20Daily) * 100 + + // 이격도 115% 이상이면 주의, 125% 이상이면 과열 + return disparityDaily > 115.0 + } + + fun calculateScores(financialScore100: Int): InvestmentScores { + val signal = generateComprehensiveSignal() // 이미 100점 만점 기반 + + // 모든 지표를 100점 스케일 내에서 조합 + val ultra = signal.compositeScore + val short = (calculateRSI(daily) * 0.5 + (if(calculateOBV(daily) > 0) 50 else 0)).toInt() + val mid = (if(calculateChange(weekly) > 0) 60 else 20) + (financialScore100 * 0.4).toInt() + val long = (if(calculateChange(monthly) > 0) 50 else 10) + (financialScore100 * 0.5).toInt() + + return InvestmentScores( + ultraShort = ultra.coerceIn(0, 100), + shortTerm = short.coerceIn(0, 100), + midTerm = mid.coerceIn(0, 100), + longTerm = long.coerceIn(0, 100) + ) + } + + // --- 유틸리티 함수군 (기존 로직 유지 및 보완) --- + fun calculateATR(candles: List, period: Int = 14): Double { + if (candles.size < period + 1) return 0.0 + val sub = candles.takeLast(period + 1) + val trList = mutableListOf() + for (i in 1 until sub.size) { + val high = sub[i].stck_hgpr.toDouble() + val low = sub[i].stck_lwpr.toDouble() + val prevClose = sub[i - 1].stck_prpr.toDouble() + trList.add(maxOf(high - low, abs(high - prevClose), abs(low - prevClose))) + } + return trList.average() + } + + fun calculateMFI(candles: List, period: Int = 14): Double { + if (candles.size < period + 1) return 50.0 + val subList = candles.takeLast(period + 1) + var posFlow = 0.0 + var negFlow = 0.0 + for (i in 1 until subList.size) { + val prevTyp = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3 + val currTyp = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3 + val flow = currTyp * subList[i].cntg_vol.toDouble() + if (currTyp > prevTyp) posFlow += flow else if (currTyp < prevTyp) negFlow += flow + } + return if (negFlow == 0.0) 100.0 else 100 - (100 / (1 + (posFlow / negFlow))) + } + + fun calculateRSI(list: List): Double { + if (list.size < 2) return 50.0 + var gains = 0.0 + var losses = 0.0 + for (i in 1 until list.size) { + val diff = list[i].stck_prpr.toDouble() - list[i-1].stck_prpr.toDouble() + if (diff > 0) gains += diff else losses -= diff + } + return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100 + } + + fun calculateOBV(candles: List): Double { + var obv = 0.0 + for (i in 1 until candles.size) { + val prevClose = candles[i-1].stck_prpr.toDouble() + val currClose = candles[i].stck_prpr.toDouble() + if (currClose > prevClose) obv += candles[i].cntg_vol.toDouble() + else if (currClose < prevClose) obv -= candles[i].cntg_vol.toDouble() + } + return obv + } + + fun calculateChange(list: List): Double { + if (list.isEmpty()) return 0.0 + val start = list.first().stck_oprc.toDouble() + val end = list.last().stck_prpr.toDouble() + return if (start != 0.0) ((end - start) / start) * 100 else 0.0 + } + + fun isDailyBullish(): Boolean { + if (daily.size < 20) return true + val currentPrice = daily.last().stck_prpr.toDouble() + val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() + val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average() + val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average() + return currentPrice > ma20 && ma5 > prevMa5 + } + + fun generateComprehensiveReport(finScore100: Int): String { + 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}]" + } + val weeklyRange = weekly.takeLast(4).joinToString(" -> ") { + "[H:${it.stck_hgpr}, L:${it.stck_lwpr}]" + } + + 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() + } +} diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index 408ccb6..6f3de54 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -27,10 +27,12 @@ import kotlinx.serialization.json.Json import model.DartFinancialResponse import model.KisSession import model.NaverNewsResponse -import service.DynamicNewsScraper -import service.FinancialAnalyzer import service.SafeScraper import service.UrlCacheManager +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale import kotlin.Double object NewsService { @@ -41,7 +43,7 @@ object NewsService { } install(Logging) { logger = Logger.DEFAULT - level = LogLevel.NONE + level = LogLevel.BODY } } @@ -63,6 +65,9 @@ object NewsService { ) (qlistNews + qlistCorpTrend).forEach { query -> try { + val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) + val today = ZonedDateTime.now().toLocalDate() // 오늘 날짜 정보 + val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") { parameter("query", query) parameter("display", 4) // 최근 10개 뉴스 @@ -70,7 +75,23 @@ object NewsService { header("X-Naver-Client-Id", clientId) header("X-Naver-Client-Secret", clientSecret) }.body() - SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(2) ) + val todayItems = response.items.filter { item -> + try { + val pubDate = ZonedDateTime.parse(item.pubDate, formatter) + pubDate.toLocalDate() == today // 날짜가 오늘과 일치하는지 확인 + } catch (e: Exception) { + false + } + } + + // 중복 호스트 제거 및 최종 2건 선택 + val finalItems = todayItems + .distinctBy { Url(it.originallink).host } + .take(2) + + if (finalItems.isNotEmpty()) { + SafeScraper.scrapeParallel(corpInfo, finalItems) + } } catch (e: Exception) { println("❌ 뉴스 가져오기 실패: ${e.message}") } diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 214fd14..90dcab6 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -3,6 +3,11 @@ package network// src/main/kotlin/network/RagService.kt import Defines.EMBEDDING_PORT import Defines.LLM_PORT import TradingLogStore +import analyzer.FinancialAnalyzer +import analyzer.FinancialMapper +import analyzer.FinancialStatement +import analyzer.InvestmentScores +import analyzer.TechnicalAnalyzer import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore import dev.langchain4j.data.document.Metadata import dev.langchain4j.data.segment.TextSegment @@ -23,6 +28,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -39,9 +45,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.apache.lucene.store.MMapDirectory import org.slf4j.MDC.put import service.AutoTradingManager -import service.FinancialAnalyzer -import service.InvestmentScores -import service.TechnicalAnalyzer +import service.InvestmentGrade import service.TradingDecisionCallback import service.UrlCacheManager import java.nio.file.Paths @@ -206,140 +210,140 @@ object RagService { } } + private fun isVeryRecentNews(dateStr: String?, maxHours: Long = 1): Boolean { + if (dateStr.isNullOrBlank()) return false + return try { + val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) + val pubDate = ZonedDateTime.parse(dateStr, formatter) + val now = ZonedDateTime.now() + + // 현재 시간과 뉴스 발행 시간의 차이를 시간 단위로 계산 + val hoursDiff = Math.abs(ChronoUnit.HOURS.between(pubDate, now)) + hoursDiff < maxHours // 1시간 미만이면 true + } catch (e: Exception) { + false + } + } + suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) { - val totalStartTime = System.currentTimeMillis() // 전체 시작 시간 + val totalStartTime = System.currentTimeMillis() coroutineScope { try { - var tradingDecision = TradingDecision() - tradingDecision.stockCode = stockCode - tradingDecision.analyzer = technicalAnalyzer - tradingDecision.currentPrice = currentPrice - - var corpInfo = DartCodeManager.getCorpCode(stockCode) - corpInfo?.stockName = stockName - tradingDecision.stockName = stockName - tradingDecision.corpName = corpInfo?.cName ?: "" - - // 1. 재무 데이터 가져오기 시간 측정 - val financialStartTime = System.currentTimeMillis() - val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } - tradingDecision.financialData = financialDataDeferred.await() - val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "") - val financialDuration = System.currentTimeMillis() - financialStartTime - println("⏱️ [$stockName] 재무 분석 소요: ${financialDuration}ms") - - if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) { - // 3. 기술적 지표 계산 시간 측정 - val techStartTime = System.currentTimeMillis() - val financialScore = FinancialAnalyzer.calculateScore(financialStmt) - val scores = technicalAnalyzer.calculateScores(financialScore) - val techDuration = System.currentTimeMillis() - techStartTime - println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms") - val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) - if (scores.avg() > (guideLine.times(0.50))) { - // 2. 뉴스 스크래핑 및 학습 시간 측정 - val ragStartTime = System.currentTimeMillis() - val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" - val questionEmbedding = embeddingModel.embed(question).content() - - // --- 💡 [수정됨] 2. 해당 주식의 최신 뉴스 존재 여부 확인 (최대 10개) --- - val preSearchResult = embeddingStore.search( - EmbeddingSearchRequest.builder() - .queryEmbedding(questionEmbedding) - .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 해당 종목만 필터링 - .maxResults(10) - .minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정 - .build() - ) - - - - // 검색된 청크들 중 최근 3일 이내의 날짜를 가진 데이터가 하나라도 있는지 확인 - val hasRecentData = preSearchResult.matches().any { match -> - val pubDate = match.embedded().metadata().getString("date") - isRecentNews(pubDate, maxDays = 1) - } - - // --- 💡 [수정됨] 3. 최신 데이터가 없을 때만 브라우저 스크래핑(Playwright) 실행 --- - val newsIngestStartTime = System.currentTimeMillis() - if (!hasRecentData) { - println("🌐 [$stockName] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다.") - corpInfo?.let { - try { - NewsService.fetchAndIngestNews(it) - } catch (e: Exception) { - println("❌ [$stockName] 뉴스 스크래핑 실패: ${e.message}") - } - } - } else { - println("✅ [$stockName] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다.") - } - val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime - println("⏱️ [$stockName] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration}ms") - - result(tradingDecision, false) - tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() - result(tradingDecision, false) - - // --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 --- - // (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다) - val finalSearchResult = embeddingStore.search( - EmbeddingSearchRequest.builder() - .queryEmbedding(questionEmbedding) - .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 교차 오염 방지를 위해 필터 필수 - .maxResults(3) - .minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정 - .build() - ) - - println("🔎 [$stockName] RAG 검색된 문서 개수: ${finalSearchResult.matches().size}개") - finalSearchResult.matches().forEach { match -> - println("📊 [RAG Score: ${match.score()}] 본문: ${match.embedded().text().replace("\n", " ").take(50)}...") - } - - tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n") { it.embedded().text() } - val ragDuration = System.currentTimeMillis() - ragStartTime - println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms") - - result(tradingDecision, false) - TradingLogStore.addAnalyzer(stockName, stockCode, "${FinancialAnalyzer.toString(financialStmt)}${scores.toString()}", true) - - // 5. AI 최종 결정(LLM 호출) 시간 측정 - val aiDecisionStartTime = System.currentTimeMillis() - val finalDecision = decideTrading(stockCode, scores, financialStmt, tradingDecision) - val aiDecisionDuration = System.currentTimeMillis() - aiDecisionStartTime - println("⏱️ [$stockName] AI 최종 판단 소요: ${aiDecisionDuration}ms") - - val totalDuration = System.currentTimeMillis() - totalStartTime - println("✅ [$stockName] 전체 분석 완료 총 소요: ${totalDuration}ms") - - // 상세 로그 남기기 - TradingLogStore.addAnalyzer(stockName, stockCode, "분석시간 상세: 재무(${financialDuration}ms), 뉴스(${newsIngestDuration}ms), RAG(${ragDuration}ms), AI(${aiDecisionDuration}ms), 전체 분석 완료(${totalDuration}ms)", true) - - result(finalDecision, true) - } else { - println("✋ [$stockName] 기술 점수 미달로 분석 중단 ${scores.toString()}") - TradingLogStore.addAnalyzer(stockName, stockCode, "기술 점수 미달로 분석 중단") - if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt)) { - TradingLogStore.addLog(tradingDecision,"WATCH","우량주로 판단되나 거래량 혹은 최근 거래 점수 미달로 재분석 대상에 추가") - AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) - } - tradingDecision.confidence = 1.0 - result(tradingDecision, false) - } - } else { - println("🚨 [$stockName] ${FinancialAnalyzer.toString(financialStmt)} 재무 안전벨트 미달") - TradingLogStore.addAnalyzer(stockName, stockCode, "재무 안전벨트 미달로 분석 중단 ${FinancialAnalyzer.toString(financialStmt)}") - tradingDecision.confidence = 1.0 - result(tradingDecision, false) + val tradingDecision = TradingDecision().apply { + this.stockCode = stockCode + this.analyzer = technicalAnalyzer + this.currentPrice = currentPrice } + + // [1단계] 재무 분석 및 필터링 (가장 빠름) + val finStartTime = System.currentTimeMillis() + val financialData = NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)?.cCode) + val financialStmt = FinancialMapper.mapRawTextToStatement(financialData) + val finDuration = System.currentTimeMillis() - finStartTime + println("financialStmt ${FinancialAnalyzer.toString(financialStmt)} isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(financialStmt)}") + + + // [2단계] 기술적 지표 및 과열 체크 + val techStartTime = System.currentTimeMillis() + val financialScore = FinancialAnalyzer.calculateScore(financialStmt) + val scores = technicalAnalyzer.calculateScores(financialScore) + val techSignal = technicalAnalyzer.generateComprehensiveSignal() + val techDuration = System.currentTimeMillis() - techStartTime + println("techSignal.compositeScore ${techSignal.compositeScore}") + + if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) { + logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime) + result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false) + return@coroutineScope + } + + if (techSignal.compositeScore < 50) { + logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime) + result(tradingDecision.apply { decision = "HOLD"; reason = "매수 타점 미도달" }, false) + return@coroutineScope + } + + // [3단계] 뉴스 RAG 및 AI 분석 (가장 오래 걸림) + val ragStartTime = System.currentTimeMillis() + // 1시간 이내 뉴스 존재 여부 확인 후 동적 스크래핑 + checkAndFetchRecentNews(stockName, stockCode) + + val question = "$stockName 실적 및 향후 전망" + val questionEmbedding = embeddingModel.embed(question).content() + + val finalSearchResult = embeddingStore.search( + EmbeddingSearchRequest.builder() + .queryEmbedding(questionEmbedding) + .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) + .maxResults(10) // 최신 뉴스 3개 적정 + .minScore(0.2) + .build() + ) + + // 3. 검색된 내용을 하나의 문자열로 합쳐서 전달 + tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n\n") { + it.embedded().text() + } + + val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision) + val ragAiDuration = System.currentTimeMillis() - ragStartTime + + // [4단계] 최종 로그 기록 + val totalDuration = System.currentTimeMillis() - totalStartTime + val detailLog = "재무(${finDuration}ms), 기술(${techDuration}ms), 뉴스/AI(${ragAiDuration}ms), 전체(${totalDuration}ms)" + TradingLogStore.addAnalyzer(stockName, stockCode, detailLog, true) + println("$stockName[$stockCode] $detailLog") + result(finalDecision, true) + } catch (e: Exception) { - e.printStackTrace() + println("❌ [$stockName] 분석 실패: ${e.message}") } } } + private suspend fun checkAndFetchRecentNews(stockName: String, stockCode: String) { + val question = "$stockName 실적 전망 및 최근 이슈" + val questionEmbedding = embeddingModel.embed(question).content() + + // 1. 벡터 DB에서 해당 종목의 뉴스 검색 + val searchResult = embeddingStore.search( + EmbeddingSearchRequest.builder() + .queryEmbedding(questionEmbedding) + .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) + .maxResults(10) + .minScore(0.2) + .build() + ) + + // 2. 검색된 뉴스 중 1시간 이내(Very Recent) 데이터가 있는지 확인 + val hasHotNews = searchResult.matches().any { match -> + val pubDate = match.embedded().metadata().getString("date") + isVeryRecentNews(pubDate, maxHours = 1) + } + + // 3. 최신 뉴스가 없다면 네이버 API 및 Playwright 스크래핑 가동 + if (!hasHotNews) { + println("🌐 [$stockName] 최근 1시간 내 분석된 뉴스가 없습니다. 실시간 스크래핑을 시작합니다.") + val corpInfo = DartCodeManager.getCorpCode(stockCode) + corpInfo?.let { + try { + // NewsService에서 오늘자 뉴스를 가져와 인덱싱 수행 + NewsService.fetchAndIngestNews(it) + } catch (e: Exception) { + println("❌ [$stockName] 뉴스 업데이트 실패: ${e.message}") + } + } + } else { + println("✅ [$stockName] 최근 1시간 내 기사가 DB에 존재하여 스크래핑을 건너뜁니다.") + } + } + + // 시간 기록용 헬퍼 함수 + private fun logTime(name: String, status: String, stepMs: Long, totalMs: Long) { + println("⏱️ [$name] $status - 단계: ${stepMs}ms / 누적: ${totalMs}ms") + } + fun isUrlAlreadyIndexed(url: String): Boolean { // 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성 val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url) @@ -371,7 +375,7 @@ object RagService { // put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화 // put("presence_penalty", 0.5) - put("max_tokens", 400) + put("max_tokens", 200) putJsonArray("messages") { addJsonObject { put("role", "system") @@ -418,128 +422,171 @@ object RagService { financialStmt: FinancialStatement, tempDecision: TradingDecision ): TradingDecision? { + val totalStartTime = System.currentTimeMillis() // 전체 시작 시간 - var retryCount = 0 - val maxRetries = 2 + // 1-1. 재무 점수 산출 시간 측정 + val finStartTime = System.currentTimeMillis() + val finScore100 = FinancialAnalyzer.calculateScore(financialStmt).toDouble() + val finDuration = System.currentTimeMillis() - finStartTime - while (retryCount <= maxRetries) { + // 1-2. 기술 분석 및 리포트 생성 시간 측정 + val techStartTime = System.currentTimeMillis() + val techSignal = tempDecision.analyzer?.generateComprehensiveSignal() + val techScore100 = techSignal?.compositeScore?.toDouble() ?: 0.0 + val isOverheated = tempDecision.analyzer?.isOverheatedStock() ?: false + tempDecision.techSummary = tempDecision.analyzer?.generateComprehensiveReport(finScore100.toInt()) + val techDuration = System.currentTimeMillis() - techStartTime - // 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단 - val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400) + // 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간) + val newsStartTime = System.currentTimeMillis() + val (newsScore100, newsReason) = tempDecision.newsContext?.let { + getAiNewsScore(it, tempDecision.techSummary ?: "") + } ?: (50.0 to "참조 뉴스 없음") + val newsDuration = System.currentTimeMillis() - newsStartTime - // 2. 뉴스 유무에 따른 동적 데이터 섹션 구성 - val newsDataSection = if (validNews != null) { - "4. News Context: $validNews" - } else { - "4. News Context: No significant news available. Rely on financials." + // 1-4. 시스템 및 가중치 합성 시간 측정 + val synthStartTime = System.currentTimeMillis() + val sysScore100 = calculateSystemPoint(scores) * 4.0 + + // 가중치 합성 (Tech 35% : Fin 25% : News 20% : Sys 20%) + var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.25) + (newsScore100 * 0.30) + (sysScore100 * 0.20) +// var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.35) + (newsScore100 * 0.20) + (sysScore100 * 0.20) + + // 보너스 및 패널티 로직 + if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0 + if (techScore100 >= 90.0 && finScore100 >= 50.0) finalConfidence += 5.0 + 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 + + // 5. 최종 결정 및 사유 정리 + val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) + var finalDecision = "HOLD" + var finalReason = "" + + when { + newsScore100 < 30.0 -> { + finalDecision = "HOLD" + finalReason = "📉 뉴스 악재 감지: $newsReason" } - - - val prompt = """ -# Task: Senior AI Investment Analyst - -Your goal is to provide a final trading decision based on STRICT data analysis. - - -# Data (SOURCE OF TRUTH) -1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm}) - -2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}% - -3. Technical Analysis Summary: ${tempDecision.techSummary ?: "No technical summary available."} - -$newsDataSection - - - -# Step-by-Step Analysis Logic - -1. Financial Review: First, evaluate the 'Financials' section for long-term stability. - -2. News Verification: Second, check 'News Context' (if available) for immediate market sentiment or specific issues. - -3. Synthesis: Finalize the 'decision' (BUY, SELL, HOLD) by combining the Financials and News analysis. - -4. Confidence: Assign a confidence score (0-100) based on how clearly the data points to the decision. - -# Confidence Scoring Guide (CRITICAL) -Assign the 'confidence' score based on these rules: -- 80-100: When Financials are strong AND News Context clearly supports the trend. -- 50-79: When Financials are stable but News is neutral or missing. -- 10-49: When Financials and News contradict each other. -- 1-9: Reserved ONLY for extreme data corruption. -- NEVER output 0 unless the data is completely unreadable. Even a weak guess should be at least 10. - -# Strict Constraints - -- SCORE INTEGRITY: You MUST copy the 'System Scores' into the output JSON exactly as provided. NO TRANSFORMATION. - -- REASON LENGTH: The "reason" field MUST be written in KOREAN and MUST be between 10 to 50 characters. - -- JSON ONLY: Output ONLY a valid JSON object. No markdown, no pre-text, no post-text. - - -# Output JSON Structure (STRICT NAMES) - -{ - "ultraShortScore": ${scores.ultraShort}, - "shortTermScore": ${scores.shortTerm}, - "midTermScore": ${scores.midTerm}, - "longTermScore": ${scores.longTerm}, - "decision": "HOLD", - "reason": "10자 이상 50자 이내의 한국어 분석 결과", - "confidence": 0 -} - -""".trimIndent() - try { - val rawResponse = callLlamaWithSchema(prompt) -// 환각 및 루프 검사 - println("rawResponse $rawResponse") - - - val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim() - - val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString(sanitized) - - // 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영) - val reasonLen = decision.reason?.length ?: 0 - val isReasonValid = reasonLen in 5..60 // 약간의 마진 허용 - - // 점수가 보존되었는지 확인 (Scalping 점수 대조) - val isScorePreserved = decision.ultraShortScore == scores.ultraShort.toDouble() - - if (isReasonValid && isScorePreserved) { - return decision.apply { - this.stockCode = tempDecision.stockCode - this.stockName = tempDecision.stockName - this.corpName = tempDecision.corpName - this.financialData = tempDecision.financialData - this.newsContext = tempDecision.newsContext - } - } else { - println("⚠️ [검증 실패] 사유길이($reasonLen) 또는 점수보존($isScorePreserved) 실패. 재시도 합니다.") - retryCount++ - } - - - - } catch (e: Exception) { - println("❌ [파싱 오류] ${e.message} - 재시도 시도 중... (${retryCount + 1})") - retryCount++ - delay(500) + isOverheated && finalConfidence < 85.0 -> { + finalDecision = "HOLD" + finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한" + } + finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_1_SPECULATIVE -> { + finalDecision = "BUY" + finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수" + } + finalConfidence < 40.0 -> { + finalDecision = "SELL" + finalReason = "⚠️ 종합 지표 악화로 인한 비중 축소 권장" + } + else -> { + finalDecision = "HOLD" + finalReason = "⏳ 지표 중립 또는 확신 부족 (신뢰도: ${String.format("%.1f", finalConfidence)})" } } - // 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환 - println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.") + + val totalDuration = System.currentTimeMillis() - totalStartTime + + // 성능 분석 로그 출력 (CSV 형태로 출력하여 나중에 엑셀 분석 가능) + println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms") + return TradingDecision().apply { + this.stockCode = tempDecision.stockCode this.stockName = stockName - this.decision = "HOLD" - this.reason = "AI 분석 지연으로 인한 자동 관망 처리" + this.currentPrice = tempDecision.currentPrice + this.techSummary = tempDecision.techSummary + this.ultraShortScore = scores.ultraShort.toDouble() + this.shortTermScore = scores.shortTerm.toDouble() + this.midTermScore = scores.midTerm.toDouble() + this.longTermScore = scores.longTerm.toDouble() + this.reason = finalReason + this.decision = finalDecision + this.confidence = finalConfidence + this.investmentGrade = grade + this.newsScore = newsScore100 + this.newsContext = tempDecision.newsContext + this.financialData = tempDecision.financialData + }.apply { + if (confidence > 50.0) { + println(this.toString()) + } } } + private suspend fun getAiNewsScore(news: String,techSummary : String): Pair { + val prompt = """ + # Role: Expert Quantitative & Sentiment Analyst + # Task: Evaluate [News Text] by correlating it with [Market Context]. + + # Input 1: [Market Context] (Standardized Scores & Price History) + $techSummary + + # Input 2: [News Text] (Latest Headlines & Content) + $news + + # Evaluation Logic (Internal Reasoning): + 1. Value Gap: Compare 'Financial Score' with 'Base Position'. (e.g., High Score + Low Position = Strong Buy) + 2. Momentum Catalyst: Check if News justifies the 'Volume Intensity' and 'Weekly Breakout'. + 3. Sentiment Weight: + - Positive: Earnings surprise, Contract win, Turnaround, Buyback. (+10~30) + - Negative: Deficit, Lawsuit, Capital increase (Dilution). (-20~40) + + # Operational Instructions: + - If the news mentions specific profit figures (e.g., "72B KRW"), award a "Profit Bonus" even without YoY comparison. + - 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. + - Output: Strictly JSON format. + + # JSON Output: + { + "score": [0.0-100.0], + "reason": "[KOREAN_REASON]" + } + """.trimIndent() + + return try { + val raw = callLlamaWithSchema(prompt) + println("getAiNewsScore $raw") + val json = Json { ignoreUnknownKeys = true }.parseToJsonElement(raw).jsonObject + + val score = json["score"]?.jsonPrimitive?.double ?: 50.0 + val reason = json["reason"]?.jsonPrimitive?.content ?: "뉴스 분석 완료" + + score to reason + } catch (e: Exception) { + 50.0 to "뉴스 분석 오류 발생 (중립 처리)" + } + } + + + // 재무 점수 계산 (Max 40) + private fun calculateFinancialPoint(fs: FinancialStatement): Double { + var p = 0.0 + // 영업이익 흑자면 25점, 적자여도 성장 중이면 10점 + p += if (fs.isOperatingProfitPositive) 25.0 else (if (fs.operatingProfitGrowth > 30) 10.0 else 0.0) + // ROE 10% 기준 비례 배분 (Max 10) + p += (fs.roe / 15.0 * 10.0).coerceIn(0.0, 10.0) + // 부채비율 100% 이하면 5점 만점 + p += if (fs.debtRatio <= 100.0) 5.0 else (150.0 - fs.debtRatio).coerceAtLeast(0.0) / 10.0 + return p + } + + private fun calculateSystemPoint(s: InvestmentScores): Double { + val midLongAvg = (s.midTerm + s.longTerm) / 2.0 + val base = (midLongAvg / 100.0 * 15.0) + (s.ultraShort / 100.0 * 10.0) + // 정배열 보너스: 초단기 > 단기 > 중기 점수 순서일 때 가점 + val alignmentBonus = if (s.ultraShort > s.shortTerm && s.shortTerm > s.midTerm) 3.0 else 0.0 + return (base + alignmentBonus).coerceIn(0.0, 25.0) + } + + } @@ -548,16 +595,18 @@ Assign the 'confidence' score based on these rules: class TradingDecision { var corpName : String = "" var stockName : String = "" - val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지) - val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스) - val midTermScore: Double = 0.0 // 중기 (주봉/재무) - val longTermScore: Double = 0.0 + var ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지) + var shortTermScore: Double = 0.0 // 단기 (일봉/뉴스) + var midTermScore: Double = 0.0 // 중기 (주봉/재무) + var longTermScore: Double = 0.0 // [추가] 화면 전환용 종목명 var currentPrice: Double = 0.0 var stockCode: String = "" var decision: String? = null var reason: String? = null var confidence: Double = 0.0 + var newsScore : Double = 0.0 + var investmentGrade : InvestmentGrade? = null var techSummary : String? = null var newsContext : String? = null var financialData : String? = null @@ -590,88 +639,13 @@ decision: $decision reason: $reason confidence: $confidence 기술 분석: $techSummary -뉴스: $newsContext -재무재표: $financialData + +뉴스 점수: $newsScore + """.trimIndent() } } +//재무재표: $financialData +//뉴스: $newsContext - -object FinancialMapper { - /** - * 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환 - */ - fun mapRawTextToStatement(rawText: String): FinancialStatement { - if (rawText.isBlank()) { - return FinancialStatement() - } -// println(rawText) - val currentValues = extractYearlyValues(rawText, "당기") - val previousValues = extractYearlyValues(rawText, "전기") - - // 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100 - val opCurrent = currentValues["영업이익"] ?: 0.0 - val opPrevious = previousValues["영업이익"] ?: 0.0 - val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0 - - // 2. 당기순이익 증가율 - val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0 - val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0 - val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0 - - // 3. ROE: 당기순이익 / 당기 자본총계 * 100 - val equityCurrent = currentValues["자본총계"] ?: 1.0 - val roe = (niCurrent / equityCurrent) * 100 - - // 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100 - val debtCurrent = currentValues["부채총계"] ?: 0.0 - val debtRatio = (debtCurrent / equityCurrent) * 100 - - // 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100 - val currentAssets = currentValues["유동자산"] ?: 0.0 - val currentLiabilities = currentValues["유동부채"] ?: 1.0 - val quickRatio = (currentAssets / currentLiabilities) * 100 - - return FinancialStatement( - operatingProfitGrowth = opGrowth, - netIncomeGrowth = niGrowth, - roe = roe, - debtRatio = debtRatio, - quickRatio = quickRatio, - isOperatingProfitPositive = opCurrent > 0, - isNetIncomePositive = niCurrent > 0 - ).apply { - println("당기순이익: ${niCurrent} , isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(this)}") - } - } - - private fun extractYearlyValues(text: String, type: String): Map { - val result = mutableMapOf() - - // 핵심 수정: 항목명 뒤에 (당기) 또는 (전기)가 오고, 그 직후의 숫자(마이너스, 쉼표 포함)를 캡처 - // 쉼표나 공백으로 끝나는 지점까지 찾습니다. - val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""") - - regex.findAll(text).forEach { match -> - val key = match.groupValues[1].trim() - // 숫자 내 쉼표 제거 후 Double 변환 - val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0 - result[key] = rawValue - } - return result - } -} - - -@Serializable -data class FinancialStatement( - val revenueGrowth: Double = 0.0, // 매출액 증가율 - val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율 - val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율 - val roe: Double = 0.0, // ROE - val debtRatio: Double = 0.0, // 부채비율 - val quickRatio: Double = 0.0, // 당좌비율 - val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부 - val isNetIncomePositive: Boolean = false -) \ No newline at end of file diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index d8ce68d..e234c8a 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -7,6 +7,7 @@ import Defines.EMBEDDING_PORT import Defines.LLM_PORT import network.TradingDecision import TradingLogStore +import analyzer.TechnicalAnalyzer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,7 +34,6 @@ import model.RankingStock import model.RankingType import model.UnifiedBalance import network.DartCodeManager -import network.FinancialStatement import network.KisAuthService import network.KisTradeService import network.KisWebSocketManager @@ -133,7 +133,7 @@ object AutoTradingManager { ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { - var investmentGrade : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) + var investmentGrade = completeTradingDecision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") @@ -189,7 +189,7 @@ object AutoTradingManager { } } - val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 + val MIN_CONFIDENCE = 60.0 // 최소 신뢰도 var append = 0.0 fun getInvestmentGrade( @@ -197,44 +197,53 @@ object AutoTradingManager { totalScore: Double, confidence: Double ): InvestmentGrade { - // 1. 기본 조건 충족 여부 - if (totalScore < 68.0 || confidence < 70.0) { - return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음) + // [개선] 하드코딩된 60/70 대신 사용자 설정 최소 점수를 기준으로 사용 + val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) + val minConfidence = minScore // 신뢰도 하한선도 매수 기준 점수와 동기화 + + // 1. 최소 기준 미달 시 (관망 대상) + if (totalScore < (minScore * 0.8) || confidence < minConfidence) { + return InvestmentGrade.LEVEL_1_SPECULATIVE } - // 2. 단기/중기/장기 패턴 기준 - val ultraShort = ts.ultraShortScore - val short = ts.shortTermScore - val mid = ts.midTermScore - val long = ts.longTermScore + // 2. 패턴 점수 추출 + val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0 + val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0 + val isOverheated = ts.analyzer?.isOverheatedStock() ?: true - val shortAvg = listOf(ultraShort, short).average() // 초단기+단기 - val midLongAvg = listOf(mid, long).average() // 중기+장기 + // 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 + else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + } - return when { - // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 - shortAvg >= 85.0 && midLongAvg >= 80.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_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 + else InvestmentGrade.LEVEL_2_HIGH_RISK + } - // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 - midLongAvg >= 75.0 && shortAvg >= 70.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + // [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태 + else -> { + if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK + else InvestmentGrade.LEVEL_1_SPECULATIVE + } + } - // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 - midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND - - // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 - shortAvg >= 75.0 && midLongAvg < 65.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK - - // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 - shortAvg >= 70.0 && midLongAvg < 55.0 -> - InvestmentGrade.LEVEL_1_SPECULATIVE - - // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) - else -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK + // 4. 단기 과열 패널티 (일괄 1단계 강등) + return if (isOverheated) { + when (rawGrade) { + InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_2_HIGH_RISK + else -> InvestmentGrade.LEVEL_1_SPECULATIVE + } + } else { + rawGrade } } @@ -384,9 +393,7 @@ object AutoTradingManager { ) } else { println("sellingAfterMarketOnePrice") -// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} , 주문 가능 : ${holding.availOrderCount}, 수익율 : ${holding.profitRate}") - if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > 0.5) { -// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ") + 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, @@ -394,30 +401,31 @@ object AutoTradingManager { "${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상" ) - targetPrice = MarketUtil.roundToTickSize(targetPrice) -// tradeService.postOrder( -// stockCode = holding.code, -// qty = holding.availOrderCount, -// price = targetPrice.toInt().toString(), -// isBuy = false, -// orderDivision = if (marketCode.equals("Y")) "07" else "", -// marketCode = if (marketCode.equals("Y")) "KRX" else "SOR" -// ).onSuccess { newOrderNo -> -// println("✅ [재주문 완료] ${holding.name}: $newOrderNo") -// TradingLogStore.addSellLog( -// holding.code, -// targetPrice.toString(), -// "SELL", -// "🎊 시간외 단일가 주식 재고털이 주문 완료" -// ) -// }.onFailure { -// TradingLogStore.addSellLog( -// holding.code, -// targetPrice.toString(), -// "SELL", -// "🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] " -// ) -// } + targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) + + tradeService.postOrder( + stockCode = holding.code, + qty = holding.availOrderCount, + price = targetPrice.toInt().toString(), + isBuy = false, + orderDivision = if (marketCode.equals("Y")) "07" else "", + marketCode = if (marketCode.equals("Y")) "KRX" else "NXT" + ).onSuccess { newOrderNo -> + println("✅ [재주문 완료] ${holding.name}: $newOrderNo") + TradingLogStore.addSellLog( + holding.code, + targetPrice.toString(), + "SELL", + "🎊 시간외 단일가 주식 재고털이 주문 완료" + ) + }.onFailure { + TradingLogStore.addSellLog( + holding.code, + targetPrice.toString(), + "SELL", + "🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] " + ) + } } delay(300) // API 호출 부하 방지 } @@ -448,12 +456,12 @@ object AutoTradingManager { val now = LocalTime.now() val currentMinute = now.minute var isBefore930 = false -// if (now.hour == 9 && currentMinute < 30) { -// targetPrice = targetPrice -// isBefore930 = true -// } else { + 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, @@ -540,12 +548,6 @@ object AutoTradingManager { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") while (isActive) { try { -// listOf("Y","X").forEach { code -> -// KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { -// sellingAfterMarketOnePrice(KisTradeService, it, code) -// } -// delay(1000) -// } now = LocalTime.now(ZoneId.of("Asia/Seoul")) currentTimeMillis = System.currentTimeMillis() lastTickTime.set(System.currentTimeMillis()) // 생존 신고 @@ -639,20 +641,6 @@ object AutoTradingManager { if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } return balance } else { -// val now = LocalTime.now() -// val currentMinute = now.minute -// if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) { -// if (lastForceCheckMinute != currentMinute) { -// listOf("Y","X").forEach { code -> -// KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let { -// sellingAfterMarketOnePrice(KisTradeService, it, code) -// } -// delay(1000) -// } -// lastForceCheckMinute = currentMinute // 실행 완료 기록 -// } -// } -// } return null } @@ -705,7 +693,7 @@ object AutoTradingManager { while (iterator.hasNext()) { totalCount-- val stock = iterator.next() - if (now.isBefore(H16) && now.isAfter(H08M35)) { +// if (now.isBefore(H16) && now.isAfter(H08M35)) { if (BLACKLISTEDSTOCKCODES.contains(stock.code)) { println("❌ 차단 처리된 주식 : ${stock.name}") } else { @@ -719,7 +707,7 @@ object AutoTradingManager { println("남은 후보군 개수 : ${totalCount}") delay(100) } - } +// } sellSchedule() } println("⏱️ [Cycle End] ${LocalTime.now()}") @@ -738,7 +726,7 @@ object AutoTradingManager { lastForceCheckMinute = currentMinute // 실행 완료 기록 } } - else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) { + else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer( " - ", @@ -959,11 +947,6 @@ object AutoTradingManager { } } - fun addStock(currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) { - scope.launch { - RagService.processStock(currentPrice,technicalAnalyzer,stockName, stockCode, result) - } - } fun checkAndRestart() { if (!isRunning()) { @@ -976,542 +959,6 @@ object AutoTradingManager { } -object FinancialAnalyzer { - - 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() - } -} - -data class InvestmentScores( - val ultraShort: Int, // 초단기 (분봉/에너지) - val shortTerm: Int, // 단기 (일봉/뉴스) - val midTerm: Int, // 중기 (주봉/재무) - val longTerm: Int // 장기 (월봉/펀더멘털) -) { - override fun toString(): String { - return """ - 평점 : ${avg()} - 초단 : $ultraShort - 단기 : $shortTerm - 중기 : $midTerm - 장기 : $longTerm - """.trimIndent() - } - - fun avg() = listOf(ultraShort, shortTerm, midTerm, longTerm).average() - -} - -@Serializable -class TechnicalAnalyzer { - var monthly: List = emptyList() - var weekly: List = emptyList() - var daily: List = emptyList() - var min30: List = emptyList() - - fun isValid() = listOf(min30,monthly, weekly,daily).filter { it.size > 0 }.size == 4 - - fun isOverheatedStock(): Boolean { - if (min30.size < 20 || daily.size < 20) return false - - val currentPrice = min30.last().stck_prpr.toDouble() - - // 1. 일봉 기준 이격도 체크 (20일 이평선 대비) - val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() - val disparityDaily = (currentPrice / ma20Daily) * 100 - // 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열) - if (disparityDaily > 125.0) return true - - // 2. 분봉(30분봉) 기준 단기 급등 체크 - val startPrice30 = min30.first().stck_oprc.toDouble() - val riseRate30 = ((currentPrice - startPrice30) / startPrice30) * 100 - // 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험 - if (riseRate30 > 15.0) return true - - // 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑) - val avgVol = min30.dropLast(3).map { it.cntg_vol.toDouble() }.average() - val recentVol = min30.last().cntg_vol.toDouble() - // 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음 - if (recentVol > avgVol * 10) return true - - // 4. 볼린저 밴드 상단 이탈 강도 - // ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인 - return false - } - - fun calculateScores( - financialScore: Int // 재무제표 점수 (성장률 등 기반) - ): InvestmentScores { - - // 1. 초단기 (분봉 + 에너지 지표 위주) - var ultra = (calculateMFI(min30, 14) * 0.4 + - calculateStochastic(min30) * 0.3 + - (if(calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt() - - // 2. 단기 (일봉 추세 + OBV 에너지) - var short = (calculateRSI(daily) * 0.3 + - (if(calculateOBV(daily) > 0) 40 else 10) + - (if(calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt() - - // 3. 중기 (주봉 + 재무 점수 혼합) - var mid = (if(calculateChange(weekly) > 0) 40 else 10) + - (financialScore * 0.6).toInt() - - // 4. 장기 (월봉 + 섹터/기업 펀더멘털) - var long = (if(calculateChange(monthly) > 0) 50 else 0) + - (financialScore * 0.5).toInt() - - // 1. 일봉 이격도 과열 체크 (20일 이평선 기준) - if (daily.size >= 20) { - val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() - val currentPrice = daily.last().stck_prpr.toDouble() - val disparityDaily = (currentPrice / ma20Daily) * 100 - - if (disparityDaily > 115.0) { // 20일선보다 15% 이상 떠 있으면 감점 시작 - val penalty = ((disparityDaily - 115.0) * 0.3).toInt() // 초과분 1%당 2점 감점 - short -= penalty - ultra -= (penalty / 2) // 초단기에도 영향 - println("⚠️ [과열 감점] 일봉 이격도(${String.format("%.1f", disparityDaily)}%): -${penalty}점") - } - } - - // 2. 주봉 급등 체크 (최근 3주간의 상승폭) - if (weekly.size >= 3) { - val weeklyChange = calculateChange(weekly.takeLast(3)) - if (weeklyChange > 30.0) { // 3주간 30% 이상 급등 시 - mid -= 6 - short -= 3 - println("⚠️ [과열 감점] 주봉 급등(${String.format("%.1f", weeklyChange)}%): -10점") - } - } - - return InvestmentScores( - ultraShort = ultra.coerceIn(0, 100), - shortTerm = short.coerceIn(0, 100), - midTerm = mid.coerceIn(0, 100), - longTerm = long.coerceIn(0, 100) - ) - } - - fun generateComprehensiveReport(): String { - // [1] 단기 에너지 지표 계산 (최근 30분봉 기준) - val obv = calculateOBV(min30) - val mfi = calculateMFI(min30, 14) - val adLine = calculateADLine(min30) - - // [2] 시계열별 가격 변동 및 추세 요약 - val m10 = min30.takeLast(10) - val change10 = calculateChange(m10) - val change30 = calculateChange(min30) - val changeDaily = calculateChange(daily.takeLast(2)) // 전일 대비 - - // [3] 이평선 및 가격 위치 - val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average() - val currentPrice = min30.last().stck_prpr.toDouble() - val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish()) - // [4] 거래량 강도 - val avgVol30 = min30.map { it.cntg_vol.toLong() }.average() - val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average() - val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0 - val atr = calculateATR(min30) - val stochK = calculateStochastic(min30) - val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() } - return """ -- 초/단타 종합 스코어: ${signal.compositeScore} / 100 -- 초/단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"} -- 초/단타 성공 확률 예측: ${signal.successProbPct}% -- 초/단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반) -- 초/단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배 -- 초/단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원) -- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"} -- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동 -- 30분 대비: ${ "%.2f".format(change30) }% 변동 -- 10분 대비: ${ "%.2f".format(change10) }% 변동 -- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"} -- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } -- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } -- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } -- 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준 -- ATR (평균 변동폭): ${"%.0f".format(atr)}원 -- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 -- 스토캐스틱(%K): ${"%.1f".format(stochK)} -- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준 -- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }} -- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }} -- RSI(14): ${ "%.1f".format(calculateRSI(min30)) } -""".trimIndent() - } - - /** - * ATR (Average True Range): 최근 변동 폭의 평균. 그래프의 '출렁임' 크기를 측정 - */ - fun calculateATR(candles: List, period: Int = 14): Double { - val sub = candles.takeLast(period + 1) - val trList = mutableListOf() - for (i in 1 until sub.size) { - val high = sub[i].stck_hgpr.toDouble() - val low = sub[i].stck_lwpr.toDouble() - val prevClose = sub[i - 1].stck_prpr.toDouble() - - val tr = maxOf(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose)) - trList.add(tr) - } - return trList.average() - } - - /** - * Stochastic (%K): 최근 가격 범위 내에서 현재가의 위치 (0~100) - * 반복되는 파동(Ups and Downs)에서 현재가 고점인지 저점인지 판단 - */ - fun calculateStochastic(candles: List, period: Int = 14): Double { - val sub = candles.takeLast(period) - val highest = sub.maxOf { it.stck_hgpr.toDouble() } - val lowest = sub.minOf { it.stck_lwpr.toDouble() } - val current = sub.last().stck_prpr.toDouble() - - return if (highest != lowest) (current - lowest) / (highest - lowest) * 100 else 50.0 - } - - private fun calculateChange(list: List): Double { - val start = list.first().stck_oprc.toDouble() - val end = list.last().stck_prpr.toDouble() - return if (start != 0.0) ((end - start) / start) * 100 else 0.0 - } - - private fun calculateRSI(list: List): Double { - if (list.size < 2) return 50.0 - var gains = 0.0 - var losses = 0.0 - for (i in 1 until list.size) { - val diff = list[i].stck_prpr.toDouble() - list[i - 1].stck_prpr.toDouble() - if (diff > 0) gains += diff else losses -= diff - } - return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100 - } - - fun isDailyBullish(): Boolean { - if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리 - - val currentPrice = daily.last().stck_prpr.toDouble() - - // 1. MA20 (한 달 생명선) 계산 - val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() - - // 2. MA5 (단기 가속도) 계산 - val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average() - - // 3. 방향성 (어제 MA5 vs 오늘 MA5) - val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average() - val isMa5Rising = ma5 > prevMa5 - - // [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주 - return currentPrice > ma20 && isMa5Rising - } - - fun calculateOBV(candles: List): Double { - var obv = 0.0 - for (i in 1 until candles.size) { - val prevClose = candles[i - 1].stck_prpr.toDouble() - val currClose = candles[i].stck_prpr.toDouble() - val currVol = candles[i].cntg_vol.toDouble() - - when { - currClose > prevClose -> obv += currVol - currClose < prevClose -> obv -= currVol - } - } - return obv - } - - /** - * MFI (Money Flow Index) 계산 (기간: 보통 14일) - */ - fun calculateMFI(candles: List, period: Int = 14): Double { - val subList = candles.takeLast(period + 1) - var posFlow = 0.0 - var negFlow = 0.0 - - for (i in 1 until subList.size) { - val prevTypical = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3 - val currTypical = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3 - val moneyFlow = currTypical * subList[i].cntg_vol.toDouble() - - if (currTypical > prevTypical) posFlow += moneyFlow - else if (currTypical < prevTypical) negFlow += moneyFlow - } - - return if (negFlow == 0.0) 100.0 else 100 - (100 / (1+ (posFlow / negFlow))) - } - - private fun calculateADLine(candles: List): Double { - var ad = 0.0 - candles.forEach { - val high = it.stck_hgpr.toDouble(); val low = it.stck_lwpr.toDouble(); val close = it.stck_prpr.toDouble() - val mfv = if (high != low) ((close - low) - (high - close)) / (high - low) else 0.0 - ad += mfv * it.cntg_vol.toDouble() - } - return ad - } - - fun clear() { - monthly = emptyList() - weekly = emptyList() - daily = emptyList() - min30 = emptyList() - } - -} - - -class ScalpingAnalyzer { - companion object { - private const val SMA_SHORT = 10 - private const val SMA_LONG = 20 - private const val RSI_WINDOW = 14 - private const val VOL_WINDOW = 20 - private const val VOL_SURGE_THRESHOLD = 1.5 - private const val RSI_THRESHOLD = 50.0 - private const val BB_LOWER_POS = 0.2 - private const val BB_UPPER_POS = 0.8 - private const val ATR_WINDOW = 14 - private const val DEFAULT_SL_PCT = -1.5 - private const val DEFAULT_TP_PCT = 1.5 - private const val HIGH_SCORE_THRESHOLD = 80 - } - - fun computeRSI(closes: List, window: Int = RSI_WINDOW): List { - val rsi = mutableListOf() - if (closes.size < window + 1) return rsi - for (i in window until closes.size) { - val gains = mutableListOf() - val losses = mutableListOf() - for (j in (i - window + 1) until i + 1) { - val delta = closes[j] - closes[j - 1] - if (delta > 0) gains.add(delta) else losses.add(abs(delta)) - } - val avgGain = gains.average() - val avgLoss = losses.average() - val rs = if (avgLoss > 0) avgGain / avgLoss else Double.POSITIVE_INFINITY - rsi.add(100.0 - (100.0 / (1.0 + rs))) - } - return rsi - } - - fun bollingerBands(closes: List, window: Int = SMA_LONG): Triple, List, List> { - val sma = mutableListOf() - val upper = mutableListOf() - val lower = mutableListOf() - for (i in window - 1 until closes.size) { - val slice = closes.subList(i - window + 1, i + 1) - val mean = slice.average() - val std = sqrt(slice.map { (it - mean).pow(2.0) }.average()) * 2.0 - sma.add(mean) - upper.add(mean + std) - lower.add(mean - std) - } - return Triple(upper, sma, lower) - } - - fun analyze(candles: List, isDailyBullish: Boolean): ScalpingSignalModel { - if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요") - - val closes = candles.map { it.close } - val volumes = candles.map { it.volume } - - // 지표 계산 - val sma10 = simpleMovingAverage(closes, SMA_SHORT) - val sma20 = simpleMovingAverage(closes, SMA_LONG) - val rsiList = computeRSI(closes) - val volAvg = simpleMovingAverage(volumes, VOL_WINDOW) - val volRatioList = volumes.mapIndexed { i, v -> if (i >= VOL_WINDOW) v / volAvg[i - VOL_WINDOW] else 0.0 } - val (bbUpper, bbMiddle, bbLower) = bollingerBands(closes) - - val current = candles.last() - val idx = candles.size - 1 - val currentClose = current.close - val sma10Now = if (sma10.size > 0) sma10.last() else 0.0 - val sma20Now = if (sma20.size > 0) sma20.last() else 0.0 - val rsiNow = if (rsiList.isNotEmpty()) rsiList.last() else 0.0 - val volRatioNow = volRatioList.last() - val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) { - (currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last()) - } else 0.5 - - - - val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high } - val isBreakout = currentClose > nearHigh - - // [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인) - val bodySize = abs(current.close - current.open) - val lowerShadow = minOf(current.close, current.open) - current.low - val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우 - - // 신호 조건 고도화 - // 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수 - -// val maBull = currentClose > sma10Now && sma10Now > sma20Now - val rsiBull = rsiNow > RSI_THRESHOLD - val volSurge = volRatioNow > VOL_SURGE_THRESHOLD - val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS - val maBull = currentClose > sma10Now && sma10Now > sma20Now -// val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout - val ma5Daily = if (candles.size >= 5) candles.takeLast(5).map { it.close.toDouble() }.average() else currentClose - val dailyDisparity = (currentClose / ma5Daily) * 100 - -// 과열 기준 정의 - val isOverheated = dailyDisparity > 110.0 // 일봉 5일선 대비 10% 이상 이격 시 과열로 간주 - -// 매수 신호 조건에 과열 방지 추가 - val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout && !isOverheated - - - val score = (if (maBull) 25 else 0) + - (if (rsiBull) 15 else 0) + - (if (isBreakout) 20 else 0) + // 돌파 에너지 가중치 - (minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() + - (if (bbGood) 10 else 0) + - (if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수 - - // 위험도 (ATR proxy) - val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 } - val atrProxy = if (returns.size >= ATR_WINDOW) { - returns.subList(returns.size - ATR_WINDOW, returns.size).average() - } else 1.0 - val riskLevel = when { - abs(atrProxy) < 1 -> "Low" - abs(atrProxy) < 2 -> "Medium" - else -> "High" - } - - // 성공 확률 & SL/TP - val successProb = if (buySignal) 75.0 else 35.0 + (score / 100.0 * 20) - val slPrice = currentClose * (1 + DEFAULT_SL_PCT / 100) - val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100) - val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT) - - - - return ScalpingSignalModel( - currentPrice = currentClose, - buySignal = buySignal, - compositeScore = minOf(score.toInt(), 100), - successProbPct = successProb, - riskLevel = riskLevel, - rsi = rsiNow, - volRatio = volRatioNow, - suggestedSlPrice = slPrice, - suggestedTpPrice = tpPrice, - riskRewardRatio = rrRatio - ) - } - - private fun simpleMovingAverage(values: List, window: Int): List { - val sma = mutableListOf() - for (i in window - 1 until values.size) { - val slice = values.subList(i - window + 1, i + 1) - sma.add(slice.average()) - } - return sma - } -} data class Candle( val timestamp: Long, @@ -1522,51 +969,6 @@ data class Candle( val volume: Double ) -data class ScalpingSignalModel( - val currentPrice: Double, - val buySignal: Boolean, - val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수) - val successProbPct: Double, // 성공 확률 추정 % - val riskLevel: String, // "Low", "Medium", "High" - val rsi: Double, - val volRatio: Double, - val suggestedSlPrice: Double, // 손절 가격 - val suggestedTpPrice: Double, // 익절 가격 - val riskRewardRatio: Double -) - -fun CandleData.toScalpingCandle(): Candle { - // 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합 - val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}" - val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") - - // 2. 타임스탬프(Epoch Milliseconds) 계산 - val timestamp = try { - val ldt = LocalDateTime.parse(dateTimeStr, formatter) - ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - } catch (e: Exception) { - // 시간 파싱 실패 시 현재 시스템 시간 사용 - System.currentTimeMillis() - } - - // 3. String 필드들을 Double로 변환하여 Candle 객체 생성 - return Candle( - timestamp = timestamp, - open = this.stck_oprc.toDoubleOrNull() ?: 0.0, - high = this.stck_hgpr.toDoubleOrNull() ?: 0.0, - low = this.stck_lwpr.toDoubleOrNull() ?: 0.0, - close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할 - volume = this.cntg_vol.toDoubleOrNull() ?: 0.0 - ) -} - -/** - * 리스트 전체를 변환하는 유틸리티 - */ -fun List.toScalpingList(): List { - return this.map { it.toScalpingCandle() } -} - enum class InvestmentGrade( val displayName: String, diff --git a/src/main/kotlin/service/LlamaServerManager.kt b/src/main/kotlin/service/LlamaServerManager.kt index dac82b2..477e05b 100644 --- a/src/main/kotlin/service/LlamaServerManager.kt +++ b/src/main/kotlin/service/LlamaServerManager.kt @@ -100,13 +100,16 @@ object LlamaServerManager { "-c", if (port == EMBEDDING_PORT) "512" else "8192", "-ngl", optimalGpuLayers.toString(), "-t", optimalThreads.toString(), - "--embedding" + "--embedding", + "--mlock", // RAM 고정으로 스왑 방지 + "--no-mmap" ) if (port != EMBEDDING_PORT) { // 텍스트 생성용 모델에만 적용 command.addAll(listOf( "-b", "512", // Batch size (토큰 병렬 처리량 제한으로 연산 안정화) "--threads-batch", optimalThreads.toString(), - "-fa","on" // Flash Attention 활성화 (메모리 절약 및 긴 컨텍스트 연산 안정성 증가) + "-fa","on", // Flash Attention 활성화 (메모리 절약 및 긴 컨텍스트 연산 안정성 증가) + "--cont-batching" )) } scope.launch { @@ -118,7 +121,7 @@ object LlamaServerManager { val env = pb.environment() // 특정 GPU 선택 (내장 GPU가 여러 개일 경우) env["GGML_VULKAN_DEVICE"] = "0" - + env["GGML_VULKAN_MAX_NODES"] = "1" // DLL 로드 경로 강제 지정 (bin 폴더 내 dll 참조) val libraryPath = File(binPath).parentFile.absolutePath val currentPath = System.getenv("PATH") ?: "" diff --git a/src/main/kotlin/ui/ActiveTradeRow.kt b/src/main/kotlin/ui/ActiveTradeRow.kt index fe29b91..bebdaa8 100644 --- a/src/main/kotlin/ui/ActiveTradeRow.kt +++ b/src/main/kotlin/ui/ActiveTradeRow.kt @@ -1,117 +1,117 @@ -// src/main/kotlin/ui/ActiveTradeRow.kt -package ui - -import AutoTradeItem -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun ActiveTradeRow( - item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용 - onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달 - onClick: () -> Unit -) { - // 상태에 따른 UI 구성 요소 정의 - val (statusText, statusColor, backgroundColor) = when (item.status) { - "PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑 - "MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑 - "SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강 - "COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색 - else -> Triple("알 수 없음", Color.Black, Color.White) - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 2.dp) - .clickable { onClick() }, - elevation = 2.dp, - shape = RoundedCornerShape(4.dp), - backgroundColor = backgroundColor - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // 좌측 정보 영역 - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - // 상태 배지 표시 - Surface( - color = statusColor, - shape = RoundedCornerShape(2.dp), - modifier = Modifier.padding(end = 6.dp) - ) { - Text( - text = statusText, - color = Color.White, - fontSize = 9.sp, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - fontWeight = FontWeight.Bold - ) - } - Text( - text = item.name, - style = MaterialTheme.typography.body2, - fontWeight = FontWeight.Bold, - maxLines = 1 - ) - } - - // 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시) - val detailText = when (item.status) { - "PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%" - "MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}" - else -> "주문번호: ${item.orderNo} ${item.orderedPrice} ${item.quantity}" - } - - Text( - text = "${item.code} | $detailText", - fontSize = 11.sp, - color = Color.Gray - ) - } - - // 우측 액션 및 수량 영역 - Column(horizontalAlignment = Alignment.End) { - if (item.status == "PENDING_BUY" || item.status == "SELLING") { - // 진행 중인 주문인 경우 취소 버튼 노출 - Button( - onClick = { onCancelClick() }, - contentPadding = PaddingValues(horizontal = 8.dp), - modifier = Modifier.height(28.dp), - colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) - ) { - Text("취소", fontSize = 11.sp) - } - } else { - Button( - onClick = { onCancelClick() }, - contentPadding = PaddingValues(horizontal = 8.dp), - modifier = Modifier.height(28.dp), - colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) - ) { - Text("취소", fontSize = 11.sp) - } - } - - Text( - text = "${item.quantity}주", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = statusColor - ) - } - } - } -} \ No newline at end of file +//// src/main/kotlin/ui/ActiveTradeRow.kt +//package ui +// +//import AutoTradeItem +//import androidx.compose.foundation.background +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.material.* +//import androidx.compose.runtime.Composable +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +// +//@Composable +//fun ActiveTradeRow( +// item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용 +// onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달 +// onClick: () -> Unit +//) { +// // 상태에 따른 UI 구성 요소 정의 +// val (statusText, statusColor, backgroundColor) = when (item.status) { +// "PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑 +// "MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑 +// "SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강 +// "COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색 +// else -> Triple("알 수 없음", Color.Black, Color.White) +// } +// +// Card( +// modifier = Modifier +// .fillMaxWidth() +// .padding(vertical = 4.dp, horizontal = 2.dp) +// .clickable { onClick() }, +// elevation = 2.dp, +// shape = RoundedCornerShape(4.dp), +// backgroundColor = backgroundColor +// ) { +// Row( +// modifier = Modifier.padding(12.dp), +// verticalAlignment = Alignment.CenterVertically +// ) { +// // 좌측 정보 영역 +// Column(modifier = Modifier.weight(1f)) { +// Row(verticalAlignment = Alignment.CenterVertically) { +// // 상태 배지 표시 +// Surface( +// color = statusColor, +// shape = RoundedCornerShape(2.dp), +// modifier = Modifier.padding(end = 6.dp) +// ) { +// Text( +// text = statusText, +// color = Color.White, +// fontSize = 9.sp, +// modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), +// fontWeight = FontWeight.Bold +// ) +// } +// Text( +// text = item.name, +// style = MaterialTheme.typography.body2, +// fontWeight = FontWeight.Bold, +// maxLines = 1 +// ) +// } +// +// // 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시) +// val detailText = when (item.status) { +// "PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%" +// "MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}" +// else -> "주문번호: ${item.orderNo} ${item.orderedPrice} ${item.quantity}" +// } +// +// Text( +// text = "${item.code} | $detailText", +// fontSize = 11.sp, +// color = Color.Gray +// ) +// } +// +// // 우측 액션 및 수량 영역 +// Column(horizontalAlignment = Alignment.End) { +// if (item.status == "PENDING_BUY" || item.status == "SELLING") { +// // 진행 중인 주문인 경우 취소 버튼 노출 +// Button( +// onClick = { onCancelClick() }, +// contentPadding = PaddingValues(horizontal = 8.dp), +// modifier = Modifier.height(28.dp), +// colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) +// ) { +// Text("취소", fontSize = 11.sp) +// } +// } else { +// Button( +// onClick = { onCancelClick() }, +// contentPadding = PaddingValues(horizontal = 8.dp), +// modifier = Modifier.height(28.dp), +// colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) +// ) { +// Text("취소", fontSize = 11.sp) +// } +// } +// +// Text( +// text = "${item.quantity}주", +// fontSize = 12.sp, +// fontWeight = FontWeight.Bold, +// color = statusColor +// ) +// } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index a06f1e4..3ea7bc4 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -1,100 +1,99 @@ -package ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.Card -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import model.KisSession -import service.AutoTradingManager -import service.TechnicalAnalyzer -import service.TradingDecisionCallback - -@Composable -fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List, tradingDecisionCallback: TradingDecisionCallback) { - var aiOpinion by remember { mutableStateOf("분석 대기 중...") } - var code by remember(stockCode) { - aiOpinion = "" - mutableStateOf(stockCode.isNotEmpty()) - } - var isAnalyzing by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - // KisSession의 전역 설정을 참조 - val isModelConfigured = remember(KisSession.config.modelPath) { - val path = KisSession.config.modelPath - path.isNotEmpty() && java.io.File(path).exists() - } - - Card( - elevation = 2.dp, - backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.15F) - .verticalScroll(rememberScrollState()) // 스크롤 활성화 - .padding(16.dp) - .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요", - fontWeight = FontWeight.Bold, - color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red - ) - Spacer(Modifier.weight(1f)) - Button( - onClick = { - scope.launch { - isAnalyzing = true - try { - AutoTradingManager.addStock(currentPrice.replace(",","").toDouble(),technicalAnalyzer,stockName,stockCode) { decision,success -> - aiOpinion = decision.toString() - isAnalyzing = !success - tradingDecisionCallback.invoke(decision,success) - } - } catch (e: Exception) { - aiOpinion = "분석 중 오류 발생: ${e.message}" - println(aiOpinion) - isAnalyzing = false - } finally { +//package ui +// +//import androidx.compose.foundation.background +//import androidx.compose.foundation.layout.Column +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.rememberScrollState +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.foundation.verticalScroll +//import androidx.compose.material.Button +//import androidx.compose.material.Card +//import androidx.compose.material.CircularProgressIndicator +//import androidx.compose.material.Divider +//import androidx.compose.material.MaterialTheme +//import androidx.compose.material.Text +//import androidx.compose.runtime.Composable +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.mutableStateOf +//import androidx.compose.runtime.remember +//import androidx.compose.runtime.rememberCoroutineScope +//import androidx.compose.runtime.setValue +//import androidx.compose.ui.* +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import kotlinx.coroutines.launch +//import model.KisSession +//import service.AutoTradingManager +//import service.TradingDecisionCallback +// +//@Composable +//fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List, tradingDecisionCallback: TradingDecisionCallback) { +// var aiOpinion by remember { mutableStateOf("분석 대기 중...") } +// var code by remember(stockCode) { +// aiOpinion = "" +// mutableStateOf(stockCode.isNotEmpty()) +// } +// var isAnalyzing by remember { mutableStateOf(false) } +// val scope = rememberCoroutineScope() +// +// // KisSession의 전역 설정을 참조 +// val isModelConfigured = remember(KisSession.config.modelPath) { +// val path = KisSession.config.modelPath +// path.isNotEmpty() && java.io.File(path).exists() +// } +// +// Card( +// elevation = 2.dp, +// backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE), +// modifier = Modifier.fillMaxWidth() +// ) { +// Column(modifier = Modifier +// .fillMaxWidth() +// .fillMaxHeight(0.15F) +// .verticalScroll(rememberScrollState()) // 스크롤 활성화 +// .padding(16.dp) +// .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) { +// Row(verticalAlignment = Alignment.CenterVertically) { +// Text( +// text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요", +// fontWeight = FontWeight.Bold, +// color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red +// ) +// Spacer(Modifier.weight(1f)) +// Button( +// onClick = { +// scope.launch { +// isAnalyzing = true +// try { +// AutoTradingManager.addStock(currentPrice.replace(",","").toDouble(),technicalAnalyzer,stockName,stockCode) { decision,success -> +// aiOpinion = decision.toString() +// isAnalyzing = !success +// tradingDecisionCallback.invoke(decision,success) +// } +// } catch (e: Exception) { +// aiOpinion = "분석 중 오류 발생: ${e.message}" +// println(aiOpinion) // isAnalyzing = false - } - } - }, - enabled = !isAnalyzing && code - ) { - if (isAnalyzing) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) - Spacer(Modifier.width(8.dp)) - Text("뉴스 분석 중...") - } else { - Text("분석 요청") - } - } - } - Divider(Modifier.padding(vertical = 8.dp)) - Text(text = aiOpinion, style = MaterialTheme.typography.body2) - } - } -} \ No newline at end of file +// } finally { +//// isAnalyzing = false +// } +// } +// }, +// enabled = !isAnalyzing && code +// ) { +// if (isAnalyzing) { +// CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) +// Spacer(Modifier.width(8.dp)) +// Text("뉴스 분석 중...") +// } else { +// Text("분석 요청") +// } +// } +// } +// Divider(Modifier.padding(vertical = 8.dp)) +// Text(text = aiOpinion, style = MaterialTheme.typography.body2) +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/AutoTradeSection.kt b/src/main/kotlin/ui/AutoTradeSection.kt index a20971b..8ce97db 100644 --- a/src/main/kotlin/ui/AutoTradeSection.kt +++ b/src/main/kotlin/ui/AutoTradeSection.kt @@ -1,95 +1,95 @@ -// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일) -package ui - -import AutoTradeItem -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import model.toAutoTradeItem -import network.KisTradeService - -// src/main/kotlin/ui/AutoTradeSection.kt - -@Composable -fun AutoTradeSection( - isDomestic: Boolean, - tradeService: KisTradeService, - refreshTrigger: Int, // 갱신 트리거 추가 - onRefresh: () -> Unit, - onItemSelect: (AutoTradeItem) -> Unit, - onItemCancel: (AutoTradeItem) -> Unit -) { - // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델) - var tradeList by remember { mutableStateOf(emptyList()) } - // refreshTrigger가 바뀔 때마다 실행됨 - LaunchedEffect(refreshTrigger) { - // 1. 서버에서 실제 미체결 내역 가져오기 - val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList() - - // 2. DB에서 로컬 감시 데이터 가져오기 - val localTrades = DatabaseFactory.getActiveAutoTrades() - - // 3. 리스트 병합 및 동기화 - val mergedList = mutableListOf() - - // (A) DB에 있는 항목 처리 - localTrades.forEach { local -> - val serverMatch = serverUnfilled.find { it.orderNo == local.orderNo } - if (local.status != "COMPLETED" && serverMatch == null) { - // 서버에 없으면 만료 처리 - mergedList.add(local.copy(status = "EXPIRED")) - } else { - // 서버에 있으면 그대로 표시 (필요시 잔량 등 업데이트) - mergedList.add(local.copy(remainedQuantity = serverMatch?.remainedQuantity ?: 0)) - } - } - - // (B) 서버에는 있지만 DB에는 없는 항목(수동 주문 등) 추가 - val manualOrders = serverUnfilled.filter { server -> localTrades.none { it.orderNo == server.orderNo } } - mergedList.addAll(manualOrders.map { it.copy(status = "MANUAL_ORDER") }) // 수동 주문 상태 등으로 표시 - - tradeList = mergedList - } - - Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) - - // 강제 갱신 버튼 - IconButton( - onClick = onRefresh, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Default.Refresh, - contentDescription = "새로고침", - tint = Color(0xFF0E62CF), - modifier = Modifier.size(18.dp) - ) - } - } - LazyColumn { - items(tradeList) { item -> - ActiveTradeRow( - item = item, - onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용 - onClick = { onItemSelect(item) } - ) - } - } - } -} \ No newline at end of file +//// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일) +//package ui +// +//import AutoTradeItem +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +//import androidx.compose.material.* +//import androidx.compose.material.icons.filled.Refresh +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import model.toAutoTradeItem +//import network.KisTradeService +// +//// src/main/kotlin/ui/AutoTradeSection.kt +// +//@Composable +//fun AutoTradeSection( +// isDomestic: Boolean, +// tradeService: KisTradeService, +// refreshTrigger: Int, // 갱신 트리거 추가 +// onRefresh: () -> Unit, +// onItemSelect: (AutoTradeItem) -> Unit, +// onItemCancel: (AutoTradeItem) -> Unit +//) { +// // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델) +// var tradeList by remember { mutableStateOf(emptyList()) } +// // refreshTrigger가 바뀔 때마다 실행됨 +// LaunchedEffect(refreshTrigger) { +// // 1. 서버에서 실제 미체결 내역 가져오기 +// val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList() +// +// // 2. DB에서 로컬 감시 데이터 가져오기 +// val localTrades = DatabaseFactory.getActiveAutoTrades() +// +// // 3. 리스트 병합 및 동기화 +// val mergedList = mutableListOf() +// +// // (A) DB에 있는 항목 처리 +// localTrades.forEach { local -> +// val serverMatch = serverUnfilled.find { it.orderNo == local.orderNo } +// if (local.status != "COMPLETED" && serverMatch == null) { +// // 서버에 없으면 만료 처리 +// mergedList.add(local.copy(status = "EXPIRED")) +// } else { +// // 서버에 있으면 그대로 표시 (필요시 잔량 등 업데이트) +// mergedList.add(local.copy(remainedQuantity = serverMatch?.remainedQuantity ?: 0)) +// } +// } +// +// // (B) 서버에는 있지만 DB에는 없는 항목(수동 주문 등) 추가 +// val manualOrders = serverUnfilled.filter { server -> localTrades.none { it.orderNo == server.orderNo } } +// mergedList.addAll(manualOrders.map { it.copy(status = "MANUAL_ORDER") }) // 수동 주문 상태 등으로 표시 +// +// tradeList = mergedList +// } +// +// Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { +// Row( +// modifier = Modifier.fillMaxWidth(), +// horizontalArrangement = Arrangement.SpaceBetween, +// verticalAlignment = Alignment.CenterVertically +// ) { +// Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) +// +// // 강제 갱신 버튼 +// IconButton( +// onClick = onRefresh, +// modifier = Modifier.size(24.dp) +// ) { +// Icon( +// imageVector = androidx.compose.material.icons.Icons.Default.Refresh, +// contentDescription = "새로고침", +// tint = Color(0xFF0E62CF), +// modifier = Modifier.size(18.dp) +// ) +// } +// } +// LazyColumn { +// items(tradeList) { item -> +// ActiveTradeRow( +// item = item, +// onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용 +// onClick = { onItemSelect(item) } +// ) +// } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/BalanceSection.kt b/src/main/kotlin/ui/BalanceSection.kt index 686177b..c2c1cce 100644 --- a/src/main/kotlin/ui/BalanceSection.kt +++ b/src/main/kotlin/ui/BalanceSection.kt @@ -1,180 +1,180 @@ -// src/main/kotlin/ui/BalanceSection.kt -package ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import model.UnifiedBalance -import network.KisTradeService - -@Composable -fun BalanceSection( - tradeService: KisTradeService, - refreshTrigger: Int, // 갱신 트리거 추가 - onRefresh: () -> Unit, - onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit -) { - var balanceData by remember { mutableStateOf(null) } - var isLoading by remember { mutableStateOf(false) } - - // 화면 진입 시 및 갱신 시 데이터 로드 - LaunchedEffect(refreshTrigger) { - isLoading = true - tradeService.fetchIntegratedBalance().onSuccess { - balanceData = it - }.onFailure { - println("❌ 잔고 로드 실패: ${it.localizedMessage}") - } - isLoading = false - } - - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "나의 자산", - style = MaterialTheme.typography.h6, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp) - ) - // 강제 갱신 버튼 - IconButton( - onClick = onRefresh, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Default.Refresh, - contentDescription = "새로고침", - tint = Color(0xFF0E62CF), - modifier = Modifier.size(18.dp) - ) - } - } - // 1. 자산 요약 카드 - BalanceSummaryCard(balanceData) - - Spacer(modifier = Modifier.height(16.dp)) - - // 2. 통합 보유 종목 리스트 - Text( - text = "보유 종목", - style = MaterialTheme.typography.subtitle1, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 8.dp) - ) - - if (isLoading) { - Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) { - CircularProgressIndicator() - } - } else { - LazyColumn(modifier = Modifier.weight(1f, true)) { - items(balanceData?.holdings ?: emptyList()) { holding -> - UnifiedStockItemRow(holding) { - onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity) - } - } - } - } - } -} - -@Composable -fun BalanceSummaryCard(summary: UnifiedBalance?) { - Card( - elevation = 2.dp, - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth(), - backgroundColor = androidx.compose.ui.graphics.Color.White - ) { - - Column(modifier = Modifier.padding(16.dp)) { - Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) - Text( - text = "${summary?.totalAsset ?: "0"} 원", - style = MaterialTheme.typography.h5, - fontWeight = FontWeight.Bold - ) - - val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0 - val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red - else if (rate < 0) androidx.compose.ui.graphics.Color.Blue - else androidx.compose.ui.graphics.Color.DarkGray - - Text( - text = "수익률: ${if (rate > 0) "+" else ""}$rate%", - color = color, - style = MaterialTheme.typography.body2, - fontWeight = FontWeight.Medium - ) - } - } -} - -@Composable -fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) { - val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0 - val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0 - - Card( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, - elevation = 1.dp - ) { - Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - // 국내/해외 구분 배지 - Surface( - color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD) - else androidx.compose.ui.graphics.Color(0xFFF3E5F5), - shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) - ) { - Text( - text = if (holding.isDomestic) "국내" else "해외", - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - fontSize = 10.sp, - color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue - else androidx.compose.ui.graphics.Color(0xFF7B1FA2) - ) - } - Spacer(Modifier.width(4.dp)) - Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) - } - Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) - } - - Column(horizontalAlignment = Alignment.End) { - Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold) - // 손익분기점 표시 추가 - Text( - "손익분기: ${String.format("%,.0f", breakEvenPrice)}원", - fontSize = 10.sp, color = Color(0xFF666666) - ) - val rate = holding.profitRate.toDoubleOrNull() ?: 0.0 - Text( - text = "${if (rate > 0) "+" else ""}${holding.profitRate}%", - color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray, - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - Text( - "매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}", - fontSize = 11.sp, color = Color.Gray - ) - } - } - } -} \ No newline at end of file +//// src/main/kotlin/ui/BalanceSection.kt +//package ui +// +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +//import androidx.compose.material.* +//import androidx.compose.material.icons.filled.Refresh +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import model.UnifiedBalance +//import network.KisTradeService +// +//@Composable +//fun BalanceSection( +// tradeService: KisTradeService, +// refreshTrigger: Int, // 갱신 트리거 추가 +// onRefresh: () -> Unit, +// onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit +//) { +// var balanceData by remember { mutableStateOf(null) } +// var isLoading by remember { mutableStateOf(false) } +// +// // 화면 진입 시 및 갱신 시 데이터 로드 +// LaunchedEffect(refreshTrigger) { +// isLoading = true +// tradeService.fetchIntegratedBalance().onSuccess { +// balanceData = it +// }.onFailure { +// println("❌ 잔고 로드 실패: ${it.localizedMessage}") +// } +// isLoading = false +// } +// +// Column(modifier = Modifier.fillMaxSize()) { +// Row( +// modifier = Modifier.fillMaxWidth(), +// horizontalArrangement = Arrangement.SpaceBetween, +// verticalAlignment = Alignment.CenterVertically +// ) { +// Text( +// text = "나의 자산", +// style = MaterialTheme.typography.h6, +// fontWeight = FontWeight.Bold, +// modifier = Modifier.padding(bottom = 8.dp) +// ) +// // 강제 갱신 버튼 +// IconButton( +// onClick = onRefresh, +// modifier = Modifier.size(24.dp) +// ) { +// Icon( +// imageVector = androidx.compose.material.icons.Icons.Default.Refresh, +// contentDescription = "새로고침", +// tint = Color(0xFF0E62CF), +// modifier = Modifier.size(18.dp) +// ) +// } +// } +// // 1. 자산 요약 카드 +// BalanceSummaryCard(balanceData) +// +// Spacer(modifier = Modifier.height(16.dp)) +// +// // 2. 통합 보유 종목 리스트 +// Text( +// text = "보유 종목", +// style = MaterialTheme.typography.subtitle1, +// fontWeight = FontWeight.Bold, +// modifier = Modifier.padding(vertical = 8.dp) +// ) +// +// if (isLoading) { +// Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) { +// CircularProgressIndicator() +// } +// } else { +// LazyColumn(modifier = Modifier.weight(1f, true)) { +// items(balanceData?.holdings ?: emptyList()) { holding -> +// UnifiedStockItemRow(holding) { +// onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity) +// } +// } +// } +// } +// } +//} +// +//@Composable +//fun BalanceSummaryCard(summary: UnifiedBalance?) { +// Card( +// elevation = 2.dp, +// shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), +// modifier = Modifier.fillMaxWidth(), +// backgroundColor = androidx.compose.ui.graphics.Color.White +// ) { +// +// Column(modifier = Modifier.padding(16.dp)) { +// Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) +// Text( +// text = "${summary?.totalAsset ?: "0"} 원", +// style = MaterialTheme.typography.h5, +// fontWeight = FontWeight.Bold +// ) +// +// val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0 +// val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red +// else if (rate < 0) androidx.compose.ui.graphics.Color.Blue +// else androidx.compose.ui.graphics.Color.DarkGray +// +// Text( +// text = "수익률: ${if (rate > 0) "+" else ""}$rate%", +// color = color, +// style = MaterialTheme.typography.body2, +// fontWeight = FontWeight.Medium +// ) +// } +// } +//} +// +//@Composable +//fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) { +// val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0 +// val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0 +// +// Card( +// modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, +// elevation = 1.dp +// ) { +// Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { +// Column(modifier = Modifier.weight(1f)) { +// Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { +// // 국내/해외 구분 배지 +// Surface( +// color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD) +// else androidx.compose.ui.graphics.Color(0xFFF3E5F5), +// shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) +// ) { +// Text( +// text = if (holding.isDomestic) "국내" else "해외", +// modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), +// fontSize = 10.sp, +// color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue +// else androidx.compose.ui.graphics.Color(0xFF7B1FA2) +// ) +// } +// Spacer(Modifier.width(4.dp)) +// Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) +// } +// Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) +// } +// +// Column(horizontalAlignment = Alignment.End) { +// Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold) +// // 손익분기점 표시 추가 +// Text( +// "손익분기: ${String.format("%,.0f", breakEvenPrice)}원", +// fontSize = 10.sp, color = Color(0xFF666666) +// ) +// val rate = holding.profitRate.toDoubleOrNull() ?: 0.0 +// Text( +// text = "${if (rate > 0) "+" else ""}${holding.profitRate}%", +// color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray, +// fontSize = 12.sp, +// fontWeight = FontWeight.Bold +// ) +// Text( +// "매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}", +// fontSize = 11.sp, color = Color.Gray +// ) +// } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/CandleChart.kt b/src/main/kotlin/ui/CandleChart.kt index 89014f3..22a38d4 100644 --- a/src/main/kotlin/ui/CandleChart.kt +++ b/src/main/kotlin/ui/CandleChart.kt @@ -1,66 +1,66 @@ -package ui - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import model.CandleData - -@Composable -fun CandleChart(data: List, modifier: Modifier = Modifier) { - if (data.isEmpty()) return - - Canvas(modifier = modifier.fillMaxSize()) { - val width = size.width - val height = size.height - - // 데이터가 적을 때도 일정한 너비를 유지하도록 개선 - val maxDisplayCount = 50 - val candleWidth = width / maxOf(data.size, maxDisplayCount) - val spacing = candleWidth * 0.2f - - // 가격 범위 계산 (여백 추가) - val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 } - val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 } - val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 } - val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백 - - fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat() - - data.forEachIndexed { index, candle -> - val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0 - val close = candle.stck_prpr.toDoubleOrNull() ?: 0.0 - val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0 - val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0 - - val isRising = close >= open - val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF) - - val x = index * candleWidth + spacing / 2 - val currentCandleWidth = candleWidth - spacing - - // 2. 꼬리 그리기 (High-Low Line) - drawLine( - color = color, - start = Offset(x + currentCandleWidth / 2, getY(high)), - end = Offset(x + currentCandleWidth / 2, getY(low)), - strokeWidth = 2f - ) - - // 3. 몸통 그리기 (Open-Close Rect) - val bodyTop = getY(maxOf(open, close)) - val bodyBottom = getY(minOf(open, close)) - val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장 - - drawRect( - color = color, - topLeft = Offset(x, bodyTop), - size = Size(currentCandleWidth, bodyHeight) - ) - } - } -} \ No newline at end of file +//package ui +// +//import androidx.compose.foundation.Canvas +//import androidx.compose.foundation.layout.fillMaxSize +//import androidx.compose.runtime.Composable +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.geometry.Offset +//import androidx.compose.ui.geometry.Size +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.graphics.drawscope.Stroke +//import model.CandleData +// +//@Composable +//fun CandleChart(data: List, modifier: Modifier = Modifier) { +// if (data.isEmpty()) return +// +// Canvas(modifier = modifier.fillMaxSize()) { +// val width = size.width +// val height = size.height +// +// // 데이터가 적을 때도 일정한 너비를 유지하도록 개선 +// val maxDisplayCount = 50 +// val candleWidth = width / maxOf(data.size, maxDisplayCount) +// val spacing = candleWidth * 0.2f +// +// // 가격 범위 계산 (여백 추가) +// val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 } +// val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 } +// val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 } +// val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백 +// +// fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat() +// +// data.forEachIndexed { index, candle -> +// val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0 +// val close = candle.stck_prpr.toDoubleOrNull() ?: 0.0 +// val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0 +// val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0 +// +// val isRising = close >= open +// val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF) +// +// val x = index * candleWidth + spacing / 2 +// val currentCandleWidth = candleWidth - spacing +// +// // 2. 꼬리 그리기 (High-Low Line) +// drawLine( +// color = color, +// start = Offset(x + currentCandleWidth / 2, getY(high)), +// end = Offset(x + currentCandleWidth / 2, getY(low)), +// strokeWidth = 2f +// ) +// +// // 3. 몸통 그리기 (Open-Close Rect) +// val bodyTop = getY(maxOf(open, close)) +// val bodyBottom = getY(minOf(open, close)) +// val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장 +// +// drawRect( +// color = color, +// topLeft = Offset(x, bodyTop), +// size = Size(currentCandleWidth, bodyHeight) +// ) +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index d98d537..f159faa 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -1,495 +1,495 @@ -// src/main/kotlin/ui/DashboardScreen.kt -package ui - -import AutoTradeItem -import network.TradingDecision -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import model.CandleData -import model.ConfigIndex -import model.ExecutionData -import model.KisSession -import model.StockBasicInfo -import network.KisTradeService -import network.KisWebSocketManager -import service.AutoTradingManager -import service.TechnicalAnalyzer -import service.TradingDecisionCallback -import util.MarketUtil -import kotlin.collections.mutableListOf - -@Composable -fun DashboardScreen() { - val tradeService = remember { KisTradeService } - val wsManager = remember { KisWebSocketManager } - val scope = rememberCoroutineScope() - var selectedStockCode by remember { mutableStateOf("") } - var selectedStockName by remember { mutableStateOf("") } - var isDomestic by remember { mutableStateOf(true) } - var selectedStockQuantity by remember { mutableStateOf("0") } - - var selectedItem by remember { mutableStateOf(null) } // 감시/미체결 아이템 선택 시 - var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 - var completeTradingDecision by remember { mutableStateOf(null) } // 단순 종목 선택 시 - - - var min30 by remember { mutableStateOf>(mutableListOf()) } - var daySummary by remember { mutableStateOf>(mutableListOf()) } - var weekSummary by remember { mutableStateOf>(mutableListOf()) } - var monthSummary by remember { mutableStateOf>(mutableListOf()) } - var yearSummary by remember { mutableStateOf>(mutableListOf()) } - - - - var callback = object : TradingDecisionCallback { - override fun invoke(decision: TradingDecision?, isSuccess: Boolean) { - if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) { - decision?.stockCode?.let { stockCode -> - decision?.stockName?.let { stockName -> - selectedStockCode = stockCode - selectedStockName = stockName - isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 - } - } - - }else if (isSuccess && decision != null) { - if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) { - selectedStockCode = decision.stockCode - selectedStockName = decision.stockName - isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 - } - // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거 - completeTradingDecision = decision - } - } - } - - -// 리소스 정리는 여전히 DisposableEffect에서 수행 - DisposableEffect(Unit) { - onDispose { - AutoTradingManager.stopDiscovery() - } - } - -// 중앙 관리용 상태들 - var refreshTrigger by remember { mutableStateOf(0) } - // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 - val executionCache = remember { mutableMapOf() } - - // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 - - LaunchedEffect(refreshTrigger) { -// setupAutoTradingWatchdog(tradeService,callback) - } - val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 - suspend fun syncAndExecute(orderNo: String) { - if (processingIds.contains(orderNo)) return - processingIds.add(orderNo) - - try { - val dbItem = DatabaseFactory.findByOrderNo(orderNo) - val execData = executionCache[orderNo] - - if (dbItem != null && execData != null && execData.isFilled) { - if (dbItem.status == TradeStatus.PENDING_BUY) { - // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) - val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice - - // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) - - val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) - - // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 - val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) - - // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 - val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) - - println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") - - tradeService.postOrder( - stockCode = dbItem.code, - qty = dbItem.quantity.toString(), - price = finalTargetPrice.toLong().toString(), - isBuy = false - ).onSuccess { newSellOrderNo -> - // 익절가 업데이트 및 상태 변경 - DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) - // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능 - - executionCache.remove(orderNo) - refreshTrigger++ - }.onFailure { - println("❌ 익절 주문 실패: ${it.message}") - } - } else if (dbItem.status == TradeStatus.SELLING) { - println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") - DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) - executionCache.remove(orderNo) - refreshTrigger++ - } - } - } finally { - processingIds.remove(orderNo) - } - } - - LaunchedEffect(Unit) { - // 1. 웹소켓 연결 - wsManager.connect() - - // 2. [기동 시 동기화 시나리오] - scope.launch { - // (1) 서버 미체결 내역 로드 - val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()) - val serverOrderNos = serverOrders.map { it.ord_no } - - // (2) DB 상태 대조 및 EXPIRED 전환 - DatabaseFactory.syncWithServer(serverOrderNos) - - // (3) 활성 감시 종목 구독 재개 - val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) - val monitoringCodes = monitoringTrades.map { it.code }.toSet() - wsManager.updateSubscriptions(monitoringCodes) - - refreshTrigger++ - } - - - - // 3. 실시간 체결 통보 핸들러 (주문번호 중심) - wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy -> - scope.launch { - val exec = ExecutionData(orderNo, code, price, qty, isBuy) - executionCache[orderNo] = exec - syncAndExecute(orderNo) - } - } - } - - Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { - // [좌측 25%] 내 자산 및 통합 잔고 - Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { - BalanceSection(tradeService, - onRefresh = { refreshTrigger++ }, - refreshTrigger = refreshTrigger) { code, name, isDom,qty -> - selectedStockCode = code - selectedStockName = name - isDomestic = isDom - selectedStockQuantity = qty - println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") - } - } - - VerticalDivider() - - // [중앙 45%] 실시간 정보 및 주문 - Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) { - if (selectedStockCode.isNotEmpty()) { - StockDetailSection( - min30 = min30, - daySummary = daySummary, - monthSummary = monthSummary, - weekSummary = weekSummary, - yearSummary = yearSummary, - stockCode = selectedStockCode, - stockName = selectedStockName, - holdingQuantity = selectedStockQuantity, - isDomestic = isDomestic, - tradeService = tradeService, - wsManager = wsManager, - onOrderSaved = { orderNo -> - scope.launch { - syncAndExecute(orderNo) // 매칭 시도 - } - }, - completeTradingDecision = completeTradingDecision, - ) - } else { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("분석할 종목을 선택하세요", color = Color.Gray) - } - } - } - - VerticalDivider() - - Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) { - AiAnalysisView( - technicalAnalyzer = TechnicalAnalyzer().apply { - this.min30 = min30 - this.daily = daySummary - this.weekly = weekSummary - this.monthly = monthSummary - this.weekly = weekSummary - }, - stockCode = selectedStockCode, - stockName = selectedStockName, - currentPrice = "0", - trades = wsManager.tradeLogs, - tradingDecisionCallback = { decision,bool -> - if (bool && decision != null && KisSession.config.isSimulation) { - completeTradingDecision = decision - } - } - ) - Spacer(modifier = Modifier.height(16.dp)) - Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(2), // 2열 병렬 배치 - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().weight(0.3f) - ) { - item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 - Text( - "💰 거래 기본 설정", - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) - ) - } - var defaults = arrayOf( - ConfigIndex.TAX_INDEX, - ConfigIndex.PROFIT_INDEX, - ConfigIndex.BUY_WEIGHT_INDEX, - ConfigIndex.MAX_BUDGET_INDEX, - ConfigIndex.MAX_PRICE_INDEX, - ConfigIndex.MIN_PRICE_INDEX, - ConfigIndex.MIN_PURCHASE_SCORE_INDEX, - ConfigIndex.MAX_COUNT_INDEX, - ) - items(defaults.size) { index -> - val configKey = defaults.get(index) - - // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) - var localText by remember(configKey) { - mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") - } - - // 저장 로직을 공통 함수로 분리 - val saveAction = { - var newValue = localText.toDoubleOrNull() ?: 0.0 - if (configKey.label.contains("PROFIT")) { - newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) - } - KisSession.config.setValues(configKey, newValue) - DatabaseFactory.saveConfig(KisSession.config) - println("💾 저장됨: ${configKey.label} = $newValue") - } - - var text = if (configKey.label.contains("PROFIT")) { - "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" - } else { - localText - } - - OutlinedTextField( - value = text, - onValueChange = { localText = it }, // 화면에는 즉시 반영 - label = { Text(configKey.label) }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> - // 2. 포커스를 잃었을 때 저장 - if (!focusState.isFocused) { - saveAction() - } - }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Decimal - ), - keyboardActions = KeyboardActions( - // 3. 엔터(Done) 키를 눌렀을 때 저장 - onDone = { - saveAction() - } - ), - singleLine = true - ) - } - item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 - Text( - "💰매수 정책 및 기대 수익률", - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) - ) - } - var defaults2 = arrayOf( - arrayOf(ConfigIndex.GRADE_5_BUY, - ConfigIndex.GRADE_5_PROFIT,), - arrayOf(ConfigIndex.GRADE_4_BUY, - ConfigIndex.GRADE_4_PROFIT,), - arrayOf(ConfigIndex.GRADE_3_BUY, - ConfigIndex.GRADE_3_PROFIT,), - arrayOf(ConfigIndex.GRADE_2_BUY, - ConfigIndex.GRADE_2_PROFIT,), - arrayOf(ConfigIndex.GRADE_1_BUY, - ConfigIndex.GRADE_1_PROFIT,), - ) - for (items in defaults2) { - val common = findLongestCommonSubstring(items.first().label,items.last().label) - item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 - Text( - common, - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) - ) - } - - items(items.size) { index -> - val configKey = items.get(index) - - // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) - var localText by remember(configKey) { - mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") - } - - var labelText by remember(configKey) { - mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") - } - - val saveAction = { - var newValue = localText.toDoubleOrNull() ?: 0.0 +//// src/main/kotlin/ui/DashboardScreen.kt +//package ui // - KisSession.config.setValues(configKey, newValue) - DatabaseFactory.saveConfig(KisSession.config) - println("💾 저장됨: ${configKey.label} = $newValue") - labelText = if (configKey.name.contains("PROFIT")) { - getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( - ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( - ConfigIndex.TAX_INDEX)}" - } else { - getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" - } - } - - labelText = if (configKey.name.contains("PROFIT")) { - getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( - ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( - ConfigIndex.TAX_INDEX)} " - } else { - getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" - } - - OutlinedTextField( - value = localText, - onValueChange = { localText = it }, // 화면에는 즉시 반영 - label = { Text(labelText) }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> - // 2. 포커스를 잃었을 때 저장 - if (!focusState.isFocused) { - saveAction() - } - }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Decimal - ), - keyboardActions = KeyboardActions( - // 3. 엔터(Done) 키를 눌렀을 때 저장 - onDone = { - saveAction() - } - ), - singleLine = true - ) - } - } - } - } - VerticalDivider() - Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { - AutoTradeSection( - isDomestic = isDomestic, - tradeService = tradeService, - onRefresh = { refreshTrigger++ }, - refreshTrigger = refreshTrigger , // 트리거 전달 - onItemCancel = { item -> - scope.launch { - tradeService.cancelOrder(item.orderNo,item.code).onSuccess { - refreshTrigger++ - } - } - }, - onItemSelect = { item -> - selectedStockCode = item.code - selectedStockName = item.name - isDomestic = item.isDomestic - }) - } - VerticalDivider() - // [우측 30%] 시장 추천 TOP 20 (실전 데이터) - Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { - MarketSection(tradeService) { code, name, isDom -> - val info = StockBasicInfo( - code = code, - name = name, - isDomestic = isDom - ) - selectedStockInfo = info - selectedStockCode = code - selectedStockName = name - isDomestic = isDom - println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") - } - } - } - - -} - - - -@Composable -fun VerticalDivider() { - Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) -} - -fun findLongestCommonSubstring(s1: String, s2: String): String { - if (s1.isEmpty() || s2.isEmpty()) return "" - - var longest = "" - // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임 - val reference = if (s1.length <= s2.length) s1 else s2 - val target = if (s1.length <= s2.length) s2 else s1 - - for (i in reference.indices) { - for (j in (i + longest.length + 1)..reference.length) { - val sub = reference.substring(i, j) - if (target.contains(sub)) { - if (sub.length > longest.length) { - longest = sub - } - } else { - // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출 - break - } - } - } - return longest -} - -fun getRemaining(original: String, common: String): String { - if (common.isEmpty()) return original - // 가장 처음 발견되는 공통 문자열을 한 번만 제거 - return original.replaceFirst(common, "").trim() -} \ No newline at end of file +//import AutoTradeItem +//import network.TradingDecision +//import androidx.compose.foundation.background +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.grid.GridCells +//import androidx.compose.foundation.lazy.grid.GridItemSpan +//import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +//import androidx.compose.foundation.text.KeyboardActions +//import androidx.compose.foundation.text.KeyboardOptions +//import androidx.compose.material.* +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.focus.onFocusChanged +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.input.ImeAction +//import androidx.compose.ui.text.input.KeyboardType +//import androidx.compose.ui.unit.dp +//import kotlinx.coroutines.launch +//import model.CandleData +//import model.ConfigIndex +//import model.ExecutionData +//import model.KisSession +//import model.StockBasicInfo +//import network.KisTradeService +//import network.KisWebSocketManager +//import service.AutoTradingManager +//import service.TechnicalAnalyzer +//import service.TradingDecisionCallback +//import util.MarketUtil +//import kotlin.collections.mutableListOf +// +//@Composable +//fun DashboardScreen() { +// val tradeService = remember { KisTradeService } +// val wsManager = remember { KisWebSocketManager } +// val scope = rememberCoroutineScope() +// var selectedStockCode by remember { mutableStateOf("") } +// var selectedStockName by remember { mutableStateOf("") } +// var isDomestic by remember { mutableStateOf(true) } +// var selectedStockQuantity by remember { mutableStateOf("0") } +// +// var selectedItem by remember { mutableStateOf(null) } // 감시/미체결 아이템 선택 시 +// var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 +// var completeTradingDecision by remember { mutableStateOf(null) } // 단순 종목 선택 시 +// +// +// var min30 by remember { mutableStateOf>(mutableListOf()) } +// var daySummary by remember { mutableStateOf>(mutableListOf()) } +// var weekSummary by remember { mutableStateOf>(mutableListOf()) } +// var monthSummary by remember { mutableStateOf>(mutableListOf()) } +// var yearSummary by remember { mutableStateOf>(mutableListOf()) } +// +// +// +// var callback = object : TradingDecisionCallback { +// override fun invoke(decision: TradingDecision?, isSuccess: Boolean) { +// if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) { +// decision?.stockCode?.let { stockCode -> +// decision?.stockName?.let { stockName -> +// selectedStockCode = stockCode +// selectedStockName = stockName +// isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 +// } +// } +// +// }else if (isSuccess && decision != null) { +// if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) { +// selectedStockCode = decision.stockCode +// selectedStockName = decision.stockName +// isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 +// } +// // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거 +// completeTradingDecision = decision +// } +// } +// } +// +// +//// 리소스 정리는 여전히 DisposableEffect에서 수행 +// DisposableEffect(Unit) { +// onDispose { +// AutoTradingManager.stopDiscovery() +// } +// } +// +//// 중앙 관리용 상태들 +// var refreshTrigger by remember { mutableStateOf(0) } +// // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 +// val executionCache = remember { mutableMapOf() } +// +// // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 +// +// LaunchedEffect(refreshTrigger) { +//// setupAutoTradingWatchdog(tradeService,callback) +// } +// val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 +// suspend fun syncAndExecute(orderNo: String) { +// if (processingIds.contains(orderNo)) return +// processingIds.add(orderNo) +// +// try { +// val dbItem = DatabaseFactory.findByOrderNo(orderNo) +// val execData = executionCache[orderNo] +// +// if (dbItem != null && execData != null && execData.isFilled) { +// if (dbItem.status == TradeStatus.PENDING_BUY) { +// // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) +// val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice +// +// // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) +// +// val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) +// +// // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 +// val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) +// +// // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 +// val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) +// +// println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") +// +// tradeService.postOrder( +// stockCode = dbItem.code, +// qty = dbItem.quantity.toString(), +// price = finalTargetPrice.toLong().toString(), +// isBuy = false +// ).onSuccess { newSellOrderNo -> +// // 익절가 업데이트 및 상태 변경 +// DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) +// // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능 +// +// executionCache.remove(orderNo) +// refreshTrigger++ +// }.onFailure { +// println("❌ 익절 주문 실패: ${it.message}") +// } +// } else if (dbItem.status == TradeStatus.SELLING) { +// println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") +// DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) +// executionCache.remove(orderNo) +// refreshTrigger++ +// } +// } +// } finally { +// processingIds.remove(orderNo) +// } +// } +// +// LaunchedEffect(Unit) { +// // 1. 웹소켓 연결 +// wsManager.connect() +// +// // 2. [기동 시 동기화 시나리오] +// scope.launch { +// // (1) 서버 미체결 내역 로드 +// val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()) +// val serverOrderNos = serverOrders.map { it.ord_no } +// +// // (2) DB 상태 대조 및 EXPIRED 전환 +// DatabaseFactory.syncWithServer(serverOrderNos) +// +// // (3) 활성 감시 종목 구독 재개 +// val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) +// val monitoringCodes = monitoringTrades.map { it.code }.toSet() +// wsManager.updateSubscriptions(monitoringCodes) +// +// refreshTrigger++ +// } +// +// +// +// // 3. 실시간 체결 통보 핸들러 (주문번호 중심) +// wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy -> +// scope.launch { +// val exec = ExecutionData(orderNo, code, price, qty, isBuy) +// executionCache[orderNo] = exec +// syncAndExecute(orderNo) +// } +// } +// } +// +// Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { +// // [좌측 25%] 내 자산 및 통합 잔고 +// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { +// BalanceSection(tradeService, +// onRefresh = { refreshTrigger++ }, +// refreshTrigger = refreshTrigger) { code, name, isDom,qty -> +// selectedStockCode = code +// selectedStockName = name +// isDomestic = isDom +// selectedStockQuantity = qty +// println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") +// } +// } +// +// VerticalDivider() +// +// // [중앙 45%] 실시간 정보 및 주문 +// Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) { +// if (selectedStockCode.isNotEmpty()) { +// StockDetailSection( +// min30 = min30, +// daySummary = daySummary, +// monthSummary = monthSummary, +// weekSummary = weekSummary, +// yearSummary = yearSummary, +// stockCode = selectedStockCode, +// stockName = selectedStockName, +// holdingQuantity = selectedStockQuantity, +// isDomestic = isDomestic, +// tradeService = tradeService, +// wsManager = wsManager, +// onOrderSaved = { orderNo -> +// scope.launch { +// syncAndExecute(orderNo) // 매칭 시도 +// } +// }, +// completeTradingDecision = completeTradingDecision, +// ) +// } else { +// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +// Text("분석할 종목을 선택하세요", color = Color.Gray) +// } +// } +// } +// +// VerticalDivider() +// +// Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) { +// AiAnalysisView( +// technicalAnalyzer = TechnicalAnalyzer().apply { +// this.min30 = min30 +// this.daily = daySummary +// this.weekly = weekSummary +// this.monthly = monthSummary +// this.weekly = weekSummary +// }, +// stockCode = selectedStockCode, +// stockName = selectedStockName, +// currentPrice = "0", +// trades = wsManager.tradeLogs, +// tradingDecisionCallback = { decision,bool -> +// if (bool && decision != null && KisSession.config.isSimulation) { +// completeTradingDecision = decision +// } +// } +// ) +// Spacer(modifier = Modifier.height(16.dp)) +// Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp)) +// LazyVerticalGrid( +// columns = GridCells.Fixed(2), // 2열 병렬 배치 +// horizontalArrangement = Arrangement.spacedBy(8.dp), +// verticalArrangement = Arrangement.spacedBy(8.dp), +// modifier = Modifier.fillMaxWidth().weight(0.3f) +// ) { +// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 +// Text( +// "💰 거래 기본 설정", +// style = MaterialTheme.typography.h6, +// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) +// ) +// } +// var defaults = arrayOf( +// ConfigIndex.TAX_INDEX, +// ConfigIndex.PROFIT_INDEX, +// ConfigIndex.BUY_WEIGHT_INDEX, +// ConfigIndex.MAX_BUDGET_INDEX, +// ConfigIndex.MAX_PRICE_INDEX, +// ConfigIndex.MIN_PRICE_INDEX, +// ConfigIndex.MIN_PURCHASE_SCORE_INDEX, +// ConfigIndex.MAX_COUNT_INDEX, +// ) +// items(defaults.size) { index -> +// val configKey = defaults.get(index) +// +// // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) +// var localText by remember(configKey) { +// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") +// } +// +// // 저장 로직을 공통 함수로 분리 +// val saveAction = { +// var newValue = localText.toDoubleOrNull() ?: 0.0 +// if (configKey.label.contains("PROFIT")) { +// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) +// } +// KisSession.config.setValues(configKey, newValue) +// DatabaseFactory.saveConfig(KisSession.config) +// println("💾 저장됨: ${configKey.label} = $newValue") +// } +// +// var text = if (configKey.label.contains("PROFIT")) { +// "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" +// } else { +// localText +// } +// +// OutlinedTextField( +// value = text, +// onValueChange = { localText = it }, // 화면에는 즉시 반영 +// label = { Text(configKey.label) }, +// modifier = Modifier +// .fillMaxWidth() +// .onFocusChanged { focusState -> +// // 2. 포커스를 잃었을 때 저장 +// if (!focusState.isFocused) { +// saveAction() +// } +// }, +// keyboardOptions = KeyboardOptions( +// imeAction = ImeAction.Done, +// keyboardType = KeyboardType.Decimal +// ), +// keyboardActions = KeyboardActions( +// // 3. 엔터(Done) 키를 눌렀을 때 저장 +// onDone = { +// saveAction() +// } +// ), +// singleLine = true +// ) +// } +// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 +// Text( +// "💰매수 정책 및 기대 수익률", +// style = MaterialTheme.typography.h6, +// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) +// ) +// } +// var defaults2 = arrayOf( +// arrayOf(ConfigIndex.GRADE_5_BUY, +// ConfigIndex.GRADE_5_PROFIT,), +// arrayOf(ConfigIndex.GRADE_4_BUY, +// ConfigIndex.GRADE_4_PROFIT,), +// arrayOf(ConfigIndex.GRADE_3_BUY, +// ConfigIndex.GRADE_3_PROFIT,), +// arrayOf(ConfigIndex.GRADE_2_BUY, +// ConfigIndex.GRADE_2_PROFIT,), +// arrayOf(ConfigIndex.GRADE_1_BUY, +// ConfigIndex.GRADE_1_PROFIT,), +// ) +// for (items in defaults2) { +// val common = findLongestCommonSubstring(items.first().label,items.last().label) +// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 +// Text( +// common, +// style = MaterialTheme.typography.h6, +// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) +// ) +// } +// +// items(items.size) { index -> +// val configKey = items.get(index) +// +// // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) +// var localText by remember(configKey) { +// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") +// } +// +// var labelText by remember(configKey) { +// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") +// } +// +// val saveAction = { +// var newValue = localText.toDoubleOrNull() ?: 0.0 +//// +// KisSession.config.setValues(configKey, newValue) +// DatabaseFactory.saveConfig(KisSession.config) +// println("💾 저장됨: ${configKey.label} = $newValue") +// labelText = if (configKey.name.contains("PROFIT")) { +// getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( +// ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( +// ConfigIndex.TAX_INDEX)}" +// } else { +// getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" +// } +// } +// +// labelText = if (configKey.name.contains("PROFIT")) { +// getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( +// ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues( +// ConfigIndex.TAX_INDEX)} " +// } else { +// getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" +// } +// +// OutlinedTextField( +// value = localText, +// onValueChange = { localText = it }, // 화면에는 즉시 반영 +// label = { Text(labelText) }, +// modifier = Modifier +// .fillMaxWidth() +// .onFocusChanged { focusState -> +// // 2. 포커스를 잃었을 때 저장 +// if (!focusState.isFocused) { +// saveAction() +// } +// }, +// keyboardOptions = KeyboardOptions( +// imeAction = ImeAction.Done, +// keyboardType = KeyboardType.Decimal +// ), +// keyboardActions = KeyboardActions( +// // 3. 엔터(Done) 키를 눌렀을 때 저장 +// onDone = { +// saveAction() +// } +// ), +// singleLine = true +// ) +// } +// } +// } +// } +// VerticalDivider() +// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { +// AutoTradeSection( +// isDomestic = isDomestic, +// tradeService = tradeService, +// onRefresh = { refreshTrigger++ }, +// refreshTrigger = refreshTrigger , // 트리거 전달 +// onItemCancel = { item -> +// scope.launch { +// tradeService.cancelOrder(item.orderNo,item.code).onSuccess { +// refreshTrigger++ +// } +// } +// }, +// onItemSelect = { item -> +// selectedStockCode = item.code +// selectedStockName = item.name +// isDomestic = item.isDomestic +// }) +// } +// VerticalDivider() +// // [우측 30%] 시장 추천 TOP 20 (실전 데이터) +// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { +// MarketSection(tradeService) { code, name, isDom -> +// val info = StockBasicInfo( +// code = code, +// name = name, +// isDomestic = isDom +// ) +// selectedStockInfo = info +// selectedStockCode = code +// selectedStockName = name +// isDomestic = isDom +// println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") +// } +// } +// } +// +// +//} +// +// +// +//@Composable +//fun VerticalDivider() { +// Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) +//} +// +//fun findLongestCommonSubstring(s1: String, s2: String): String { +// if (s1.isEmpty() || s2.isEmpty()) return "" +// +// var longest = "" +// // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임 +// val reference = if (s1.length <= s2.length) s1 else s2 +// val target = if (s1.length <= s2.length) s2 else s1 +// +// for (i in reference.indices) { +// for (j in (i + longest.length + 1)..reference.length) { +// val sub = reference.substring(i, j) +// if (target.contains(sub)) { +// if (sub.length > longest.length) { +// longest = sub +// } +// } else { +// // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출 +// break +// } +// } +// } +// return longest +//} +// +//fun getRemaining(original: String, common: String): String { +// if (common.isEmpty()) return original +// // 가장 처음 발견되는 공통 문자열을 한 번만 제거 +// return original.replaceFirst(common, "").trim() +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 0f1ae8a..6a8536b 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -1,504 +1,504 @@ -// src/main/kotlin/ui/IntegratedOrderSection.kt -package ui - -import AutoTradeItem -import network.TradingDecision -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import model.ConfigIndex -import model.KisSession -import model.RankingStock -import network.KisTradeService -import service.AutoTradingManager -import service.InvestmentGrade -import util.MarketUtil -import kotlin.math.min -import kotlin.math.roundToInt - - - - -/** - * 통합 주문 및 자동매매 설정 섹션 - * * [수정 사항] - * 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지 - * 2. 수량 입력 시 콤마 제거 및 Int 변환 예외 처리 적용 - * 3. 매수 성공 시 반환받은 실제 주문번호(ODNO)를 DB에 저장하여 주문번호 중심 관리 구현 - */ -@Composable -fun IntegratedOrderSection( - stockCode: String, - stockName: String, - isDomestic: Boolean, - currentPrice: String, - holdingQuantity: String, - tradeService: KisTradeService, - onOrderSaved: (String) -> Unit, - onOrderResult: (String, Boolean) -> Unit, - completeTradingDecision: TradingDecision? -) { - val scope = rememberCoroutineScope() - - // 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리 - var monitoringItem by remember(stockCode) { - mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) - } - - var activeMonitoringItem by remember(stockCode) { - mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) - } - - // 2. 체크박스의 '의도' 상태 (신규 매수 시 자동감시를 켤 것인지 여부) - // 감시 중인 아이템이 있으면 true, 없으면 사용자 선택에 따름 - var willEnableAutoSell by remember(stockCode) { - mutableStateOf(activeMonitoringItem != null) - } - - - - // UI 입력 상태 - var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 - var orderQty by remember(holdingQuantity) { - // 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리) - val cleanQty = holdingQuantity.replace(",", "") - mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty) - } - - var profitRate by remember(monitoringItem) { - mutableStateOf(monitoringItem?.profitRate?.toString() ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX).toString()) - } - var stopLossRate by remember(monitoringItem) { - mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5") - } - var basePrice: Double = 0.0 - LaunchedEffect(currentPrice) { - val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 - basePrice = curPriceNum - } - // 계산용 변수 - - - - fun getInvestmentGrade( - ts: TradingDecision, - totalScore: Double, - confidence: Double - ): InvestmentGrade { - // 1. 기본 조건 충족 여부 - if (totalScore < 68.0 || confidence < 70.0) { - return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음) - } - - // 2. 단기/중기/장기 패턴 기준 - val ultraShort = ts.ultraShortScore - val short = ts.shortTermScore - val mid = ts.midTermScore - val long = ts.longTermScore - - val shortAvg = listOf(ultraShort, short).average() // 초단기+단기 - val midLongAvg = listOf(mid, long).average() // 중기+장기 - - return when { - // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 - shortAvg >= 85.0 && midLongAvg >= 80.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND - - // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 - midLongAvg >= 75.0 && shortAvg >= 70.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND - - // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 - midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND - - // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 - shortAvg >= 75.0 && midLongAvg < 65.0 -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK - - // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 - shortAvg >= 70.0 && midLongAvg < 55.0 -> - InvestmentGrade.LEVEL_1_SPECULATIVE - - // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) - else -> - if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK - } - } - - fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { - scope.launch { - val tickSize = MarketUtil.getTickSize(basePrice) - val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) - - // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) - val finalPrice = MarketUtil.roundToTickSize(if (orderPrice.isBlank()) { - oneTickLowerPrice.toDouble() - } else { - orderPrice.toDouble() - }) - - println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") - tradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) - .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 - println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice") - onOrderResult("주문 성공: $realOrderNo", true) - if (willEnableAutoSell) { - // 1. 기본 설정값 파싱 - val pRate = profitRate.toDoubleOrNull() ?: 0.0 - val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 - -// 2. 수수료 및 세금 보정치 설정 (국내 주식 기준 약 0.25% ~ 0.3%) -// 유관기관 수수료 및 매도세금을 고려하여 안전하게 0.3%로 잡거나, 필요시 더 높게 설정 가능합니다. - -// 3. 실질 목표 수익률 계산 -// 사용자가 입력한 pRate와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다. - var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) - val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues( - ConfigIndex.PROFIT_INDEX) + tax)) - -// 4. 보정된 수익률을 적용하여 목표가 계산 - val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) - val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) - val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 -// 5. DB 저장 (effectiveProfitRate를 저장하여 분석 시 실제 목표치를 확인 가능하게 함) - DatabaseFactory.saveAutoTrade(AutoTradeItem( - orderNo = realOrderNo, - code = stockCode, - name = stockName, - quantity = inputQty, - profitRate = effectiveProfitRate, // 보정된 수익률 저장 - stopLossRate = sRate, - targetPrice = calculatedTarget, - stopLossPrice = calculatedStop, - status = "PENDING_BUY", - isDomestic = isDomestic - )) - monitoringItem = DatabaseFactory.findConfigByCode(stockCode) - onOrderSaved(realOrderNo) - onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo", true) - } - } - .onFailure { - println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") - onOrderResult(it.message ?: "매수 실패", false) - } - } - } - LaunchedEffect(completeTradingDecision) { - val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 - val MIN_SAFE_SCORE = 65.0 // 최소 중기 점수 (주봉/재무) - val MIN_POSSIBLE_SCORE = 55.0 // 최소 중기 점수 (주봉/재무) - val MIN_SHORT_SCORE = 60.0 // 최소 중기 점수 (주봉/재무) - var append = 0.0 - if (completeTradingDecision != null && - completeTradingDecision.stockCode.equals(stockCode)) { - basePrice = completeTradingDecision.currentPrice - println("basePrice $basePrice") - val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) - var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) - val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX) - val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) - - fun resultCheck(completeTradingDecision :TradingDecision) { - val weights = mapOf( - "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 - "profit" to 0.4, - "safe" to 0.4 // 중장기 점수 비중 강화 - ) - - val totalScore = - ((completeTradingDecision.shortPossible() + append) * weights["short"]!!) + - ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) + - ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) - - if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { - var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) - - val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) - println(""" - 사명 : ${completeTradingDecision.corpName} - 신뢰도 : ${completeTradingDecision.confidence + append} - 단기성 : ${completeTradingDecision.shortPossible() + append} - 수익성 : ${completeTradingDecision.profitPossible()+ append} - 안전성 : ${completeTradingDecision.safePossible()+ append} - ${investmentGrade.displayName} : ${investmentGrade.description} - 총점 : ${totalScore} - """.trimIndent()) - println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") - - // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) - val gradeRate = (1.0 - (investmentGrade.ordinal * 0.1)) - val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt() - maxBudget = maxBudget * gradeRate - val calculatedQty = if (basePrice > 0) { - (maxBudget / basePrice).toInt().coerceAtLeast(1) - } else { - 1 - } - // 5. 매수 실행 (계산된 finalMargin 전달) - excuteTrade( - willEnableAutoSell = true, - orderQty = min(calculatedQty, maxQty).toString(), - profitRate1 = finalMargin, - investmentGrade = investmentGrade, - ) - - } else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) { - AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) - println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가") - } else { - println("✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") - } - } - when (completeTradingDecision?.decision) { - "BUY" -> { - append = buyWeight - println("[$stockCode] 매수 추천 : ${completeTradingDecision?.reason}") - resultCheck(completeTradingDecision) - } - "SELL" -> { - println("[$stockCode] 매도: ${completeTradingDecision?.reason}") - } - "HOLD" -> { - append = 0.0 - resultCheck(completeTradingDecision) - println("[$stockCode] 관망 유지 : ${completeTradingDecision?.reason}") - } - else -> { - append = 0.0 - println("[$stockCode] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason}") - } - } - } - } - Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) - - // 가격 및 수량 입력 필드 - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - AutoResizeOutlinedTextField( - value = orderQty, - onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it }, - label = { Text("수량") }, - modifier = Modifier.weight(1f) - ) - AutoResizeOutlinedTextField( - value = orderPrice, - onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it }, - label = { Text("가격") }, - placeholder = { Text("시장가 (${currentPrice})") }, - modifier = Modifier.weight(1f) - ) - } - val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 - // 수익률 시뮬레이션 표 - if (basePrice > 0 && inputQty > 0) { - SimulationCard(basePrice, inputQty.toDouble()) - } - - Spacer(modifier = Modifier.height(4.dp)) - - // 실시간 AI 매도 감시 설정 카드 - Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) { - Column(modifier = Modifier.padding(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = willEnableAutoSell, - onCheckedChange = { checked -> - willEnableAutoSell = checked - if (!checked) { - // [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지) - monitoringItem?.id?.let { dbId -> - DatabaseFactory.deleteAutoTrade(dbId) - monitoringItem = null - println("🗑️ 감시 해제: $stockName (ID: $dbId)") - } - } else { - // [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작 -// if (curPriceNum > 0) { -// val pRate = profitRate.toDoubleOrNull() ?: 0.0 -// val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 -// val target = curPriceNum * (1 + pRate / 100.0) -// val stop = curPriceNum * (1 + sRate / 100.0) +//// src/main/kotlin/ui/IntegratedOrderSection.kt +//package ui // -// val newItem = AutoTradeItem( -// orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}", -// code = stockCode, -// name = stockName, -// quantity = inputQty, -// profitRate = pRate, -// stopLossRate = sRate, -// targetPrice = target, -// stopLossPrice = stop, -// status = "MONITORING", -// isDomestic = isDomestic -// ) -// DatabaseFactory.saveAutoTrade(newItem) -// monitoringItem = DatabaseFactory.findConfigByCode(stockCode) +//import AutoTradeItem +//import network.TradingDecision +//import androidx.compose.foundation.interaction.MutableInteractionSource +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.foundation.text.BasicTextField +//import androidx.compose.material.* +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.TextStyle +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.text.input.VisualTransformation +//import androidx.compose.ui.text.rememberTextMeasurer +//import androidx.compose.ui.unit.TextUnit +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import kotlinx.coroutines.launch +//import model.ConfigIndex +//import model.KisSession +//import model.RankingStock +//import network.KisTradeService +//import service.AutoTradingManager +//import service.InvestmentGrade +//import util.MarketUtil +//import kotlin.math.min +//import kotlin.math.roundToInt +// +// +// +// +///** +// * 통합 주문 및 자동매매 설정 섹션 +// * * [수정 사항] +// * 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지 +// * 2. 수량 입력 시 콤마 제거 및 Int 변환 예외 처리 적용 +// * 3. 매수 성공 시 반환받은 실제 주문번호(ODNO)를 DB에 저장하여 주문번호 중심 관리 구현 +// */ +//@Composable +//fun IntegratedOrderSection( +// stockCode: String, +// stockName: String, +// isDomestic: Boolean, +// currentPrice: String, +// holdingQuantity: String, +// tradeService: KisTradeService, +// onOrderSaved: (String) -> Unit, +// onOrderResult: (String, Boolean) -> Unit, +// completeTradingDecision: TradingDecision? +//) { +// val scope = rememberCoroutineScope() +// +// // 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리 +// var monitoringItem by remember(stockCode) { +// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) +// } +// +// var activeMonitoringItem by remember(stockCode) { +// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) +// } +// +// // 2. 체크박스의 '의도' 상태 (신규 매수 시 자동감시를 켤 것인지 여부) +// // 감시 중인 아이템이 있으면 true, 없으면 사용자 선택에 따름 +// var willEnableAutoSell by remember(stockCode) { +// mutableStateOf(activeMonitoringItem != null) +// } +// +// +// +// // UI 입력 상태 +// var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 +// var orderQty by remember(holdingQuantity) { +// // 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리) +// val cleanQty = holdingQuantity.replace(",", "") +// mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty) +// } +// +// var profitRate by remember(monitoringItem) { +// mutableStateOf(monitoringItem?.profitRate?.toString() ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX).toString()) +// } +// var stopLossRate by remember(monitoringItem) { +// mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5") +// } +// var basePrice: Double = 0.0 +// LaunchedEffect(currentPrice) { +// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 +// basePrice = curPriceNum +// } +// // 계산용 변수 +// +// +// +// fun getInvestmentGrade( +// ts: TradingDecision, +// totalScore: Double, +// confidence: Double +// ): InvestmentGrade { +// // 1. 기본 조건 충족 여부 +// if (totalScore < 68.0 || confidence < 70.0) { +// return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음) +// } +// +// // 2. 단기/중기/장기 패턴 기준 +// val ultraShort = ts.ultraShortScore +// val short = ts.shortTermScore +// val mid = ts.midTermScore +// val long = ts.longTermScore +// +// val shortAvg = listOf(ultraShort, short).average() // 초단기+단기 +// val midLongAvg = listOf(mid, long).average() // 중기+장기 +// +// return when { +// // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 +// shortAvg >= 85.0 && midLongAvg >= 80.0 -> +// if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND +// +// // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 +// midLongAvg >= 75.0 && shortAvg >= 70.0 -> +// if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND +// +// // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 +// midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> +// if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND +// +// // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 +// shortAvg >= 75.0 && midLongAvg < 65.0 -> +// if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK +// +// // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 +// shortAvg >= 70.0 && midLongAvg < 55.0 -> +// InvestmentGrade.LEVEL_1_SPECULATIVE +// +// // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) +// else -> +// if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK +// } +// } +// +// fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { +// scope.launch { +// val tickSize = MarketUtil.getTickSize(basePrice) +// val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) +// +// // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) +// val finalPrice = MarketUtil.roundToTickSize(if (orderPrice.isBlank()) { +// oneTickLowerPrice.toDouble() +// } else { +// orderPrice.toDouble() +// }) +// +// println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") +// tradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) +// .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 +// println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice") +// onOrderResult("주문 성공: $realOrderNo", true) +// if (willEnableAutoSell) { +// // 1. 기본 설정값 파싱 +// val pRate = profitRate.toDoubleOrNull() ?: 0.0 +// val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 +// +//// 2. 수수료 및 세금 보정치 설정 (국내 주식 기준 약 0.25% ~ 0.3%) +//// 유관기관 수수료 및 매도세금을 고려하여 안전하게 0.3%로 잡거나, 필요시 더 높게 설정 가능합니다. +// +//// 3. 실질 목표 수익률 계산 +//// 사용자가 입력한 pRate와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다. +// var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) +// val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues( +// ConfigIndex.PROFIT_INDEX) + tax)) +// +//// 4. 보정된 수익률을 적용하여 목표가 계산 +// val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) +// val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) +// val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 +//// 5. DB 저장 (effectiveProfitRate를 저장하여 분석 시 실제 목표치를 확인 가능하게 함) +// DatabaseFactory.saveAutoTrade(AutoTradeItem( +// orderNo = realOrderNo, +// code = stockCode, +// name = stockName, +// quantity = inputQty, +// profitRate = effectiveProfitRate, // 보정된 수익률 저장 +// stopLossRate = sRate, +// targetPrice = calculatedTarget, +// stopLossPrice = calculatedStop, +// status = "PENDING_BUY", +// isDomestic = isDomestic +// )) +// monitoringItem = DatabaseFactory.findConfigByCode(stockCode) +// onOrderSaved(realOrderNo) +// onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo", true) +// } +// } +// .onFailure { +// println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") +// onOrderResult(it.message ?: "매수 실패", false) +// } +// } +// } +// LaunchedEffect(completeTradingDecision) { +// val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 +// val MIN_SAFE_SCORE = 65.0 // 최소 중기 점수 (주봉/재무) +// val MIN_POSSIBLE_SCORE = 55.0 // 최소 중기 점수 (주봉/재무) +// val MIN_SHORT_SCORE = 60.0 // 최소 중기 점수 (주봉/재무) +// var append = 0.0 +// if (completeTradingDecision != null && +// completeTradingDecision.stockCode.equals(stockCode)) { +// basePrice = completeTradingDecision.currentPrice +// println("basePrice $basePrice") +// val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) +// var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) +// val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX) +// val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) +// +// fun resultCheck(completeTradingDecision :TradingDecision) { +// val weights = mapOf( +// "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 +// "profit" to 0.4, +// "safe" to 0.4 // 중장기 점수 비중 강화 +// ) +// +// val totalScore = +// ((completeTradingDecision.shortPossible() + append) * weights["short"]!!) + +// ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) + +// ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) +// +// if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { +// var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) +// +// val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) +// println(""" +// 사명 : ${completeTradingDecision.corpName} +// 신뢰도 : ${completeTradingDecision.confidence + append} +// 단기성 : ${completeTradingDecision.shortPossible() + append} +// 수익성 : ${completeTradingDecision.profitPossible()+ append} +// 안전성 : ${completeTradingDecision.safePossible()+ append} +// ${investmentGrade.displayName} : ${investmentGrade.description} +// 총점 : ${totalScore} +// """.trimIndent()) +// println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") +// +// // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) +// val gradeRate = (1.0 - (investmentGrade.ordinal * 0.1)) +// val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt() +// maxBudget = maxBudget * gradeRate +// val calculatedQty = if (basePrice > 0) { +// (maxBudget / basePrice).toInt().coerceAtLeast(1) +// } else { +// 1 +// } +// // 5. 매수 실행 (계산된 finalMargin 전달) +// excuteTrade( +// willEnableAutoSell = true, +// orderQty = min(calculatedQty, maxQty).toString(), +// profitRate1 = finalMargin, +// investmentGrade = investmentGrade, +// ) +// +// } else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) { +// AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) +// println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가") +// } else { +// println("✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") +// } +// } +// when (completeTradingDecision?.decision) { +// "BUY" -> { +// append = buyWeight +// println("[$stockCode] 매수 추천 : ${completeTradingDecision?.reason}") +// resultCheck(completeTradingDecision) +// } +// "SELL" -> { +// println("[$stockCode] 매도: ${completeTradingDecision?.reason}") +// } +// "HOLD" -> { +// append = 0.0 +// resultCheck(completeTradingDecision) +// println("[$stockCode] 관망 유지 : ${completeTradingDecision?.reason}") +// } +// else -> { +// append = 0.0 +// println("[$stockCode] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason}") +// } +// } +// } +// } +// Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { +// Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) +// +// // 가격 및 수량 입력 필드 +// Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { +// AutoResizeOutlinedTextField( +// value = orderQty, +// onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it }, +// label = { Text("수량") }, +// modifier = Modifier.weight(1f) +// ) +// AutoResizeOutlinedTextField( +// value = orderPrice, +// onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it }, +// label = { Text("가격") }, +// placeholder = { Text("시장가 (${currentPrice})") }, +// modifier = Modifier.weight(1f) +// ) +// } +// val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 +// // 수익률 시뮬레이션 표 +// if (basePrice > 0 && inputQty > 0) { +// SimulationCard(basePrice, inputQty.toDouble()) +// } +// +// Spacer(modifier = Modifier.height(4.dp)) +// +// // 실시간 AI 매도 감시 설정 카드 +// Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) { +// Column(modifier = Modifier.padding(4.dp)) { +// Row(verticalAlignment = Alignment.CenterVertically) { +// Checkbox( +// checked = willEnableAutoSell, +// onCheckedChange = { checked -> +// willEnableAutoSell = checked +// if (!checked) { +// // [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지) +// monitoringItem?.id?.let { dbId -> +// DatabaseFactory.deleteAutoTrade(dbId) +// monitoringItem = null +// println("🗑️ 감시 해제: $stockName (ID: $dbId)") // } - } - } - ) - Text("실시간 AI 매도 감시 활성화", fontSize = 12.sp, fontWeight = FontWeight.Bold) - } - - Row { - AutoResizeOutlinedTextField( - value = profitRate, onValueChange = { profitRate = it }, - label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp), - ) - AutoResizeOutlinedTextField( - value = stopLossRate, onValueChange = { stopLossRate = it }, - label = { Text("손절 %") }, modifier = Modifier.weight(1f), - ) - } - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - // 매수 / 매도 실행 버튼 - Row(modifier = Modifier.fillMaxWidth()) { - // 매수 버튼 - Button( - onClick = { - excuteTrade(willEnableAutoSell, orderQty, profitRate.toDouble()) - }, - modifier = Modifier.weight(1f).padding(end = 4.dp), - colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) - ) { Text("매수", color = Color.White) } - - // 매도 버튼 - Button( - onClick = { - scope.launch { - val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice - tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = false) - .onSuccess { realOrderNo -> - onOrderResult("매도 주문 성공: $realOrderNo", true) - // 매도 시 기존 감시 설정이 있다면 상태 변경 등 추가 로직 가능 - } - .onFailure { onOrderResult(it.message ?: "매도 실패", false) } - } - }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) - ) { Text("매도", color = Color.White) } - } - } - - -} - -@Composable -fun SimulationCard(basePrice: Double, qty: Double) { - Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) { - Column(modifier = Modifier.padding(8.dp)) { - Text("수익률 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 10.sp, color = Color.Gray) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%")) - SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }) - SimulationColumn("예상수령", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate -> - val netAmount = (basePrice * rate * qty) * (1 - 0.0022) - String.format("%,d", netAmount.toLong()) - }) - } - } - } -} - -@Composable -fun SimulationColumn(title: String, items: List) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray) - items.forEach { text -> - val color = when { - text.contains("+") -> Color(0xFFE03E2D) - text.contains("-") -> Color(0xFF0E62CF) - else -> Color.Black - } - Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp)) - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun AutoResizeOutlinedTextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - label: @Composable (() -> Unit)? = null, // 라벨 추가 - placeholder: @Composable (() -> Unit)? = null, // 플레이스홀더 추가 - maxFontSize: TextUnit = 20.sp, - minFontSize: TextUnit = 8.sp -) { - val textMeasurer = rememberTextMeasurer() - var fontSize by remember { mutableStateOf(maxFontSize) } - val interactionSource = remember { MutableInteractionSource() } - - BoxWithConstraints(modifier = modifier) { - val maxWidthPx = constraints.maxWidth - - // 텍스트 너비에 따른 폰트 크기 자동 축소 로직 - LaunchedEffect(value) { - var currentSize = maxFontSize - while (currentSize > minFontSize) { - val layoutResult = textMeasurer.measure( - text = value, - style = TextStyle(fontSize = currentSize) - ) - if (layoutResult.size.width <= maxWidthPx) break - currentSize = (currentSize.value - 0.5f).sp - } - fontSize = currentSize - } - - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle(fontSize = fontSize, color = Color.Black), - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, - singleLine = true, - decorationBox = { innerTextField -> - TextFieldDefaults.OutlinedTextFieldDecorationBox( - value = value, - innerTextField = innerTextField, - enabled = true, - singleLine = true, - visualTransformation = VisualTransformation.None, - interactionSource = interactionSource, - // [핵심] 사용자가 정의한 라벨과 플레이스홀더 연결 - label = label, - placeholder = placeholder, - // [핵심] 내부 패딩 0.dp 설정 - contentPadding = PaddingValues(0.dp), - border = { - TextFieldDefaults.BorderBox( - enabled = true, - isError = false, - interactionSource = interactionSource, - colors = TextFieldDefaults.outlinedTextFieldColors() - ) - } - ) - } - ) - } -} \ No newline at end of file +// } else { +// // [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작 +//// if (curPriceNum > 0) { +//// val pRate = profitRate.toDoubleOrNull() ?: 0.0 +//// val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 +//// val target = curPriceNum * (1 + pRate / 100.0) +//// val stop = curPriceNum * (1 + sRate / 100.0) +//// +//// val newItem = AutoTradeItem( +//// orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}", +//// code = stockCode, +//// name = stockName, +//// quantity = inputQty, +//// profitRate = pRate, +//// stopLossRate = sRate, +//// targetPrice = target, +//// stopLossPrice = stop, +//// status = "MONITORING", +//// isDomestic = isDomestic +//// ) +//// DatabaseFactory.saveAutoTrade(newItem) +//// monitoringItem = DatabaseFactory.findConfigByCode(stockCode) +//// } +// } +// } +// ) +// Text("실시간 AI 매도 감시 활성화", fontSize = 12.sp, fontWeight = FontWeight.Bold) +// } +// +// Row { +// AutoResizeOutlinedTextField( +// value = profitRate, onValueChange = { profitRate = it }, +// label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp), +// ) +// AutoResizeOutlinedTextField( +// value = stopLossRate, onValueChange = { stopLossRate = it }, +// label = { Text("손절 %") }, modifier = Modifier.weight(1f), +// ) +// } +// } +// } +// +// Spacer(modifier = Modifier.height(4.dp)) +// +// // 매수 / 매도 실행 버튼 +// Row(modifier = Modifier.fillMaxWidth()) { +// // 매수 버튼 +// Button( +// onClick = { +// excuteTrade(willEnableAutoSell, orderQty, profitRate.toDouble()) +// }, +// modifier = Modifier.weight(1f).padding(end = 4.dp), +// colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) +// ) { Text("매수", color = Color.White) } +// +// // 매도 버튼 +// Button( +// onClick = { +// scope.launch { +// val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice +// tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = false) +// .onSuccess { realOrderNo -> +// onOrderResult("매도 주문 성공: $realOrderNo", true) +// // 매도 시 기존 감시 설정이 있다면 상태 변경 등 추가 로직 가능 +// } +// .onFailure { onOrderResult(it.message ?: "매도 실패", false) } +// } +// }, +// modifier = Modifier.weight(1f), +// colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) +// ) { Text("매도", color = Color.White) } +// } +// } +// +// +//} +// +//@Composable +//fun SimulationCard(basePrice: Double, qty: Double) { +// Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) { +// Column(modifier = Modifier.padding(8.dp)) { +// Text("수익률 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 10.sp, color = Color.Gray) +// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { +// SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%")) +// SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }) +// SimulationColumn("예상수령", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate -> +// val netAmount = (basePrice * rate * qty) * (1 - 0.0022) +// String.format("%,d", netAmount.toLong()) +// }) +// } +// } +// } +//} +// +//@Composable +//fun SimulationColumn(title: String, items: List) { +// Column(horizontalAlignment = Alignment.CenterHorizontally) { +// Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray) +// items.forEach { text -> +// val color = when { +// text.contains("+") -> Color(0xFFE03E2D) +// text.contains("-") -> Color(0xFF0E62CF) +// else -> Color.Black +// } +// Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp)) +// } +// } +//} +// +//@OptIn(ExperimentalMaterialApi::class) +//@Composable +//fun AutoResizeOutlinedTextField( +// value: String, +// onValueChange: (String) -> Unit, +// modifier: Modifier = Modifier, +// label: @Composable (() -> Unit)? = null, // 라벨 추가 +// placeholder: @Composable (() -> Unit)? = null, // 플레이스홀더 추가 +// maxFontSize: TextUnit = 20.sp, +// minFontSize: TextUnit = 8.sp +//) { +// val textMeasurer = rememberTextMeasurer() +// var fontSize by remember { mutableStateOf(maxFontSize) } +// val interactionSource = remember { MutableInteractionSource() } +// +// BoxWithConstraints(modifier = modifier) { +// val maxWidthPx = constraints.maxWidth +// +// // 텍스트 너비에 따른 폰트 크기 자동 축소 로직 +// LaunchedEffect(value) { +// var currentSize = maxFontSize +// while (currentSize > minFontSize) { +// val layoutResult = textMeasurer.measure( +// text = value, +// style = TextStyle(fontSize = currentSize) +// ) +// if (layoutResult.size.width <= maxWidthPx) break +// currentSize = (currentSize.value - 0.5f).sp +// } +// fontSize = currentSize +// } +// +// BasicTextField( +// value = value, +// onValueChange = onValueChange, +// textStyle = TextStyle(fontSize = fontSize, color = Color.Black), +// modifier = Modifier.fillMaxWidth(), +// interactionSource = interactionSource, +// singleLine = true, +// decorationBox = { innerTextField -> +// TextFieldDefaults.OutlinedTextFieldDecorationBox( +// value = value, +// innerTextField = innerTextField, +// enabled = true, +// singleLine = true, +// visualTransformation = VisualTransformation.None, +// interactionSource = interactionSource, +// // [핵심] 사용자가 정의한 라벨과 플레이스홀더 연결 +// label = label, +// placeholder = placeholder, +// // [핵심] 내부 패딩 0.dp 설정 +// contentPadding = PaddingValues(0.dp), +// border = { +// TextFieldDefaults.BorderBox( +// enabled = true, +// isError = false, +// interactionSource = interactionSource, +// colors = TextFieldDefaults.outlinedTextFieldColors() +// ) +// } +// ) +// } +// ) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/MarketSection.kt b/src/main/kotlin/ui/MarketSection.kt index 611dc19..2e81ae7 100644 --- a/src/main/kotlin/ui/MarketSection.kt +++ b/src/main/kotlin/ui/MarketSection.kt @@ -1,101 +1,101 @@ -// src/main/kotlin/ui/MarketSection.kt -package ui - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.material.TabRowDefaults.tabIndicatorOffset -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import model.RankingStock -import model.RankingType -import network.KisTradeService - -@Composable -fun MarketSection( - tradeService: KisTradeService, - onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit -) { - var selectedTab by remember { mutableStateOf(RankingType.VOLUME) } - var isDomestic by remember { mutableStateOf(true) } - var rankingList by remember { mutableStateOf>(emptyList()) } - var isLoading by remember { mutableStateOf(false) } - - // 탭 또는 국가 변경 시 데이터 로드 - LaunchedEffect(selectedTab, isDomestic) { - isLoading = true - tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess { - rankingList = it - }.onFailure { - rankingList = emptyList() - } - isLoading = false - } - - Column(modifier = Modifier.fillMaxSize()) { - // [1] 상단 타이틀 및 국내/해외 토글 - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) - - // 국내/해외 전환 스위치 방식 - Row(verticalAlignment = Alignment.CenterVertically) { - Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium) - Switch( - checked = !isDomestic, - onCheckedChange = { isDomestic = !it }, - colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF)) - ) - } - } - - // [2] 랭킹 타입 탭 (상승, 하락, 거래량 등) - ScrollableTabRow( - selectedTabIndex = selectedTab.ordinal, - backgroundColor = Color.Transparent, - contentColor = Color.Black, - edgePadding = 0.dp, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]), - color = Color(0xFFE03E2D) - ) - } - ) { - RankingType.values().forEach { type -> - Tab( - selected = selectedTab == type, - onClick = { selectedTab = type }, - text = { Text(type.title, fontSize = 12.sp) } - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // [3] 랭킹 리스트 - if (isLoading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(strokeWidth = 2.dp) - } - } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(rankingList.withIndex().toList()) { (index, stock) -> - MarketStockItemRow(index + 1, stock) { - onStockSelect(stock.code, stock.name, isDomestic) - } - Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp) - } - } - } - } -} \ No newline at end of file +//// src/main/kotlin/ui/MarketSection.kt +//package ui +// +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +//import androidx.compose.material.* +//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import model.RankingStock +//import model.RankingType +//import network.KisTradeService +// +//@Composable +//fun MarketSection( +// tradeService: KisTradeService, +// onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit +//) { +// var selectedTab by remember { mutableStateOf(RankingType.VOLUME) } +// var isDomestic by remember { mutableStateOf(true) } +// var rankingList by remember { mutableStateOf>(emptyList()) } +// var isLoading by remember { mutableStateOf(false) } +// +// // 탭 또는 국가 변경 시 데이터 로드 +// LaunchedEffect(selectedTab, isDomestic) { +// isLoading = true +// tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess { +// rankingList = it +// }.onFailure { +// rankingList = emptyList() +// } +// isLoading = false +// } +// +// Column(modifier = Modifier.fillMaxSize()) { +// // [1] 상단 타이틀 및 국내/해외 토글 +// Row( +// modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.SpaceBetween +// ) { +// Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) +// +// // 국내/해외 전환 스위치 방식 +// Row(verticalAlignment = Alignment.CenterVertically) { +// Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium) +// Switch( +// checked = !isDomestic, +// onCheckedChange = { isDomestic = !it }, +// colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF)) +// ) +// } +// } +// +// // [2] 랭킹 타입 탭 (상승, 하락, 거래량 등) +// ScrollableTabRow( +// selectedTabIndex = selectedTab.ordinal, +// backgroundColor = Color.Transparent, +// contentColor = Color.Black, +// edgePadding = 0.dp, +// indicator = { tabPositions -> +// TabRowDefaults.Indicator( +// Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]), +// color = Color(0xFFE03E2D) +// ) +// } +// ) { +// RankingType.values().forEach { type -> +// Tab( +// selected = selectedTab == type, +// onClick = { selectedTab = type }, +// text = { Text(type.title, fontSize = 12.sp) } +// ) +// } +// } +// +// Spacer(modifier = Modifier.height(8.dp)) +// +// // [3] 랭킹 리스트 +// if (isLoading) { +// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +// CircularProgressIndicator(strokeWidth = 2.dp) +// } +// } else { +// LazyColumn(modifier = Modifier.fillMaxSize()) { +// items(rankingList.withIndex().toList()) { (index, stock) -> +// MarketStockItemRow(index + 1, stock) { +// onStockSelect(stock.code, stock.name, isDomestic) +// } +// Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp) +// } +// } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/MarketStockItemRow.kt b/src/main/kotlin/ui/MarketStockItemRow.kt index f36fc25..b071afb 100644 --- a/src/main/kotlin/ui/MarketStockItemRow.kt +++ b/src/main/kotlin/ui/MarketStockItemRow.kt @@ -1,62 +1,62 @@ -package ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import model.RankingStock -import model.RankingType -import network.KisTradeService - - -// src/main/kotlin/ui/MarketStockItemRow.kt -@Composable -fun MarketStockItemRow( - rank: Int, - stock: RankingStock, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // 순위 표시 - Text( - text = rank.toString(), - modifier = Modifier.width(24.dp), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray - ) - - Column(modifier = Modifier.weight(1f)) { - Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1) - Text(stock.code, fontSize = 10.sp, color = Color.Gray) - } - - Column(horizontalAlignment = Alignment.End) { - Text( - text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L), - fontSize = 13.sp, - fontWeight = FontWeight.Bold - ) - val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 - Text( - text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%", - fontSize = 11.sp, - color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray - ) - } - } -} \ No newline at end of file +//package ui +// +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +//import androidx.compose.material.* +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import model.RankingStock +//import model.RankingType +//import network.KisTradeService +// +// +//// src/main/kotlin/ui/MarketStockItemRow.kt +//@Composable +//fun MarketStockItemRow( +// rank: Int, +// stock: RankingStock, +// onClick: () -> Unit +//) { +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .clickable { onClick() } +// .padding(vertical = 10.dp, horizontal = 4.dp), +// verticalAlignment = Alignment.CenterVertically +// ) { +// // 순위 표시 +// Text( +// text = rank.toString(), +// modifier = Modifier.width(24.dp), +// fontSize = 14.sp, +// fontWeight = FontWeight.Bold, +// color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray +// ) +// +// Column(modifier = Modifier.weight(1f)) { +// Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1) +// Text(stock.code, fontSize = 10.sp, color = Color.Gray) +// } +// +// Column(horizontalAlignment = Alignment.End) { +// Text( +// text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L), +// fontSize = 13.sp, +// fontWeight = FontWeight.Bold +// ) +// val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 +// Text( +// text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%", +// fontSize = 11.sp, +// color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray +// ) +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/PeriodTrendCard.kt b/src/main/kotlin/ui/PeriodTrendCard.kt index 8afaada..90c21fa 100644 --- a/src/main/kotlin/ui/PeriodTrendCard.kt +++ b/src/main/kotlin/ui/PeriodTrendCard.kt @@ -1,57 +1,57 @@ -// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합) -package ui - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import model.CandleData - -@Composable -fun PeriodTrendCard(label: String, data: List, modifier: Modifier = Modifier) { - val avgPrice = if (data.isEmpty()) "0" - else String.format("%,d", data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average().toLong()) - - Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) { - Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { - // [좌측] 라벨 및 평균가 - Column(modifier = Modifier.weight(0.4f)) { - Text(label, fontSize = 10.sp, color = Color.Gray) - Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold) - } - - // [우측] 간소화된 그래프 (Sparkline) - Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) { - if (data.isNotEmpty()) { - Canvas(modifier = Modifier.fillMaxSize()) { - val prices = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 } - val max = prices.maxOrNull() ?: 1.0 - val min = prices.minOrNull() ?: 0.0 - val range = if (max == min) 1.0 else max - min - - val stepX = size.width / (prices.size - 1).coerceAtLeast(1) - val points = prices.mapIndexed { i, p -> - Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat()) - } - - for (i in 0 until points.size - 1) { - drawLine( - color = if (prices.last() >= prices.first()) Color(0xFFE03E2D) else Color(0xFF0E62CF), - start = points[i], - end = points[i + 1], - strokeWidth = 2f - ) - } - } - } - } - } - } -} \ No newline at end of file +//// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합) +//package ui +// +//import androidx.compose.foundation.Canvas +//import androidx.compose.foundation.layout.* +//import androidx.compose.material.* +//import androidx.compose.runtime.Composable +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.geometry.Offset +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import model.CandleData +// +//@Composable +//fun PeriodTrendCard(label: String, data: List, modifier: Modifier = Modifier) { +// val avgPrice = if (data.isEmpty()) "0" +// else String.format("%,d", data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average().toLong()) +// +// Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) { +// Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { +// // [좌측] 라벨 및 평균가 +// Column(modifier = Modifier.weight(0.4f)) { +// Text(label, fontSize = 10.sp, color = Color.Gray) +// Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold) +// } +// +// // [우측] 간소화된 그래프 (Sparkline) +// Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) { +// if (data.isNotEmpty()) { +// Canvas(modifier = Modifier.fillMaxSize()) { +// val prices = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 } +// val max = prices.maxOrNull() ?: 1.0 +// val min = prices.minOrNull() ?: 0.0 +// val range = if (max == min) 1.0 else max - min +// +// val stepX = size.width / (prices.size - 1).coerceAtLeast(1) +// val points = prices.mapIndexed { i, p -> +// Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat()) +// } +// +// for (i in 0 until points.size - 1) { +// drawLine( +// color = if (prices.last() >= prices.first()) Color(0xFFE03E2D) else Color(0xFF0E62CF), +// start = points[i], +// end = points[i + 1], +// strokeWidth = 2f +// ) +// } +// } +// } +// } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/RealTimeTradeList.kt b/src/main/kotlin/ui/RealTimeTradeList.kt index b6de593..813491e 100644 --- a/src/main/kotlin/ui/RealTimeTradeList.kt +++ b/src/main/kotlin/ui/RealTimeTradeList.kt @@ -1,43 +1,43 @@ -// src/main/kotlin/ui/RealTimeTradeList.kt -package ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import model.RealTimeTrade - -@Composable -fun RealTimeTradeList(tradeLogs: List) { - Column(modifier = Modifier.fillMaxSize()) { - // [1] 리스트 헤더 영역 - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFFEEEEEE)) // 연한 회색 배경 - .padding(vertical = 4.dp) - ) { - Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) - Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) - Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) - Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) - } - - // [2] 실제 데이터 리스트 - LazyColumn(modifier = Modifier.fillMaxSize()) { - // 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴) - items(tradeLogs) { trade -> - TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용 - Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp) - } - } - } -} \ No newline at end of file +//// src/main/kotlin/ui/RealTimeTradeList.kt +//package ui +// +//import androidx.compose.foundation.background +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items +//import androidx.compose.material.Divider +//import androidx.compose.material.Text +//import androidx.compose.runtime.Composable +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.style.TextAlign +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import model.RealTimeTrade +// +//@Composable +//fun RealTimeTradeList(tradeLogs: List) { +// Column(modifier = Modifier.fillMaxSize()) { +// // [1] 리스트 헤더 영역 +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .background(Color(0xFFEEEEEE)) // 연한 회색 배경 +// .padding(vertical = 4.dp) +// ) { +// Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) +// Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) +// Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) +// Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray) +// } +// +// // [2] 실제 데이터 리스트 +// LazyColumn(modifier = Modifier.fillMaxSize()) { +// // 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴) +// items(tradeLogs) { trade -> +// TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용 +// Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp) +// } +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 5042952..eab56af 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -1,302 +1,302 @@ -package ui - - - -import network.TradingDecision -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -// 아래 두 import가 'delegate' 에러를 해결합니다. -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import model.CandleData -import network.DartCodeManager -import network.KisTradeService -import network.KisWebSocketManager -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import kotlin.collections.isNotEmpty - -@Composable -fun StockDetailSection( - stockCode: String, - stockName: String, - holdingQuantity: String, - isDomestic: Boolean, - tradeService: KisTradeService, - wsManager: KisWebSocketManager, - onOrderSaved: (String) -> Unit, - completeTradingDecision: TradingDecision?, - min30 : MutableList, - daySummary : MutableList, - weekSummary : MutableList, - monthSummary : MutableList, - yearSummary : MutableList -) { - -// var openPrice by remember { mutableStateOf("0") } - var chartData by remember { mutableStateOf>(emptyList()) } - var isLoading by remember { mutableStateOf(false) } - var resultMessage by remember { mutableStateOf("") } - var isSuccess by remember { mutableStateOf(true) } - - - val todayOpen = remember(daySummary) { - daySummary.lastOrNull()?.stck_oprc ?: "0" - } - val previousClose = remember(daySummary) { - if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0" - } - - - - // 이전 종목 코드를 기억하기 위한 상태 - var previousCode by remember { mutableStateOf("") } - var lastPrice by remember { mutableStateOf("0") } - - - // 종목 변경 시 데이터 로드 및 웹소켓 구독 관리 - LaunchedEffect(stockCode) { - if (stockCode.isEmpty()) return@LaunchedEffect - - isLoading = true - - // 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독 - if (previousCode.isNotEmpty()) { - wsManager.unsubscribeStock(previousCode) - } - wsManager.clearData() - wsManager.subscribeStock(stockCode) - previousCode = stockCode - - - // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) - - coroutineScope { - launch { - wsManager.onPriceUpdate = {tradeLog -> - - - if (tradeLog.code.equals(stockCode)) { - val code = tradeLog.code - val price = tradeLog.price - wsManager.tradeLogs.add(tradeLog) - if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast() -// println("code $code ,price $price") - val currentPrice = price - if (chartData.isNotEmpty() && currentPrice != "0") { - val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 - val lastCandle = chartData.last() - - // 현재 시간(분 단위) 확인 - val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00")) - - if (lastCandle.stck_bsop_date != currentMinute) { - // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) - val newCandle = CandleData( - stck_bsop_date = currentMinute, - stck_oprc = currentPrice, - stck_hgpr = currentPrice, - stck_lwpr = currentPrice, - stck_prpr = currentPrice, - stck_cntg_hour = currentMinute, - cntg_vol = "1", - acml_tr_pbmn = "1", - ) - // 최대 100개까지만 유지하여 성능 최적화 - chartData = (chartData + newCandle).takeLast(100) - } else { - // 같은 분 내에서는 기존 마지막 캔들만 업데이트 - val updatedCandle = lastCandle.copy( - stck_prpr = currentPrice, - stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr, - stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr - ) - chartData = chartData.dropLast(1) + updatedCandle - } - } - lastPrice = currentPrice - } - - } - } - launch {tradeService.fetchChartData(stockCode, isDomestic) - .onSuccess { data -> -// println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 - chartData = data - min30.clear() - min30.addAll(chartData) - } - .onFailure { error -> -// println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") - chartData = emptyList() - } - } - launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { - daySummary.clear() - daySummary.addAll(it) - } - } // 최근 7일 - launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { - weekSummary.clear() - weekSummary.addAll(it.takeLast(4)) -// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") - } - } // 최근 4주 - launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { - monthSummary.clear() - monthSummary.addAll(it.takeLast(6)) - yearSummary.clear() - yearSummary.addAll(it.takeLast(36)) - } - } - launch { - DartCodeManager.getCorpCode(stockCode)?.let { - it.stockName = stockName -// NewsService.fetchAndIngestNews(it) - } - } - } - isLoading = false - } - - - -// LaunchedEffect(latestPrice) { -// println("latestPrice >>> $latestPrice") -// if (chartData.isNotEmpty() && latestPrice != "0") { -// val latestPrice = latestPrice ?: "0" -// val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect -// val lastCandle = chartData.last() +//package ui // -// // 현재 시간(분 단위) 확인 -// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00")) // -// if (lastCandle.stck_bsop_date != currentMinute) { -// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) -// val newCandle = CandleData( -// stck_bsop_date = currentMinute, -// stck_oprc = latestPrice, -// stck_hgpr = latestPrice, -// stck_lwpr = latestPrice, -// stck_prpr = latestPrice, -// stck_cntg_hour = currentMinute, -// cntg_vol = "1", -// acml_tr_pbmn = "1", +// +//import network.TradingDecision +//import androidx.compose.foundation.layout.* +//import androidx.compose.material.* +//import androidx.compose.runtime.* +//// 아래 두 import가 'delegate' 에러를 해결합니다. +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.setValue +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import kotlinx.coroutines.coroutineScope +//import kotlinx.coroutines.launch +//import model.CandleData +//import network.DartCodeManager +//import network.KisTradeService +//import network.KisWebSocketManager +//import java.time.LocalTime +//import java.time.format.DateTimeFormatter +//import kotlin.collections.isNotEmpty +// +//@Composable +//fun StockDetailSection( +// stockCode: String, +// stockName: String, +// holdingQuantity: String, +// isDomestic: Boolean, +// tradeService: KisTradeService, +// wsManager: KisWebSocketManager, +// onOrderSaved: (String) -> Unit, +// completeTradingDecision: TradingDecision?, +// min30 : MutableList, +// daySummary : MutableList, +// weekSummary : MutableList, +// monthSummary : MutableList, +// yearSummary : MutableList +//) { +// +//// var openPrice by remember { mutableStateOf("0") } +// var chartData by remember { mutableStateOf>(emptyList()) } +// var isLoading by remember { mutableStateOf(false) } +// var resultMessage by remember { mutableStateOf("") } +// var isSuccess by remember { mutableStateOf(true) } +// +// +// val todayOpen = remember(daySummary) { +// daySummary.lastOrNull()?.stck_oprc ?: "0" +// } +// val previousClose = remember(daySummary) { +// if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0" +// } +// +// +// +// // 이전 종목 코드를 기억하기 위한 상태 +// var previousCode by remember { mutableStateOf("") } +// var lastPrice by remember { mutableStateOf("0") } +// +// +// // 종목 변경 시 데이터 로드 및 웹소켓 구독 관리 +// LaunchedEffect(stockCode) { +// if (stockCode.isEmpty()) return@LaunchedEffect +// +// isLoading = true +// +// // 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독 +// if (previousCode.isNotEmpty()) { +// wsManager.unsubscribeStock(previousCode) +// } +// wsManager.clearData() +// wsManager.subscribeStock(stockCode) +// previousCode = stockCode +// +// +// // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) +// +// coroutineScope { +// launch { +// wsManager.onPriceUpdate = {tradeLog -> +// +// +// if (tradeLog.code.equals(stockCode)) { +// val code = tradeLog.code +// val price = tradeLog.price +// wsManager.tradeLogs.add(tradeLog) +// if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast() +//// println("code $code ,price $price") +// val currentPrice = price +// if (chartData.isNotEmpty() && currentPrice != "0") { +// val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 +// val lastCandle = chartData.last() +// +// // 현재 시간(분 단위) 확인 +// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00")) +// +// if (lastCandle.stck_bsop_date != currentMinute) { +// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) +// val newCandle = CandleData( +// stck_bsop_date = currentMinute, +// stck_oprc = currentPrice, +// stck_hgpr = currentPrice, +// stck_lwpr = currentPrice, +// stck_prpr = currentPrice, +// stck_cntg_hour = currentMinute, +// cntg_vol = "1", +// acml_tr_pbmn = "1", +// ) +// // 최대 100개까지만 유지하여 성능 최적화 +// chartData = (chartData + newCandle).takeLast(100) +// } else { +// // 같은 분 내에서는 기존 마지막 캔들만 업데이트 +// val updatedCandle = lastCandle.copy( +// stck_prpr = currentPrice, +// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr, +// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr +// ) +// chartData = chartData.dropLast(1) + updatedCandle +// } +// } +// lastPrice = currentPrice +// } +// +// } +// } +// launch {tradeService.fetchChartData(stockCode, isDomestic) +// .onSuccess { data -> +//// println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 +// chartData = data +// min30.clear() +// min30.addAll(chartData) +// } +// .onFailure { error -> +//// println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") +// chartData = emptyList() +// } +// } +// launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { +// daySummary.clear() +// daySummary.addAll(it) +// } +// } // 최근 7일 +// launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { +// weekSummary.clear() +// weekSummary.addAll(it.takeLast(4)) +//// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") +// } +// } // 최근 4주 +// launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { +// monthSummary.clear() +// monthSummary.addAll(it.takeLast(6)) +// yearSummary.clear() +// yearSummary.addAll(it.takeLast(36)) +// } +// } +// launch { +// DartCodeManager.getCorpCode(stockCode)?.let { +// it.stockName = stockName +//// NewsService.fetchAndIngestNews(it) +// } +// } +// } +// isLoading = false +// } +// +// +// +//// LaunchedEffect(latestPrice) { +//// println("latestPrice >>> $latestPrice") +//// if (chartData.isNotEmpty() && latestPrice != "0") { +//// val latestPrice = latestPrice ?: "0" +//// val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect +//// val lastCandle = chartData.last() +//// +//// // 현재 시간(분 단위) 확인 +//// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00")) +//// +//// if (lastCandle.stck_bsop_date != currentMinute) { +//// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) +//// val newCandle = CandleData( +//// stck_bsop_date = currentMinute, +//// stck_oprc = latestPrice, +//// stck_hgpr = latestPrice, +//// stck_lwpr = latestPrice, +//// stck_prpr = latestPrice, +//// stck_cntg_hour = currentMinute, +//// cntg_vol = "1", +//// acml_tr_pbmn = "1", +//// ) +//// // 최대 100개까지만 유지하여 성능 최적화 +//// chartData = (chartData + newCandle).takeLast(100) +//// } else { +//// // 같은 분 내에서는 기존 마지막 캔들만 업데이트 +//// val updatedCandle = lastCandle.copy( +//// stck_prpr = latestPrice, +//// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr, +//// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr +//// ) +//// chartData = chartData.dropLast(1) + updatedCandle +//// } +//// } +//// } +// +// Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { +// // [상단] 종목명 및 상태 메시지 +// Row( +// modifier = Modifier.fillMaxWidth(), +// horizontalArrangement = Arrangement.SpaceBetween, +// verticalAlignment = Alignment.CenterVertically +// ) { +// StockHeader( +// name = stockName, +// code = stockCode, +// isDomestic = isDomestic, +// previousClose = previousClose, +// openPrice = lastPrice, +// resultMessage = resultMessage, +// resultMessageClear = {resultMessage = ""}, +// isSuccess = isSuccess +// ) +// +// // 실시간 가격 표시 (WebSocket 데이터) +// Column(horizontalAlignment = Alignment.End) { +// Text( +// text = "${lastPrice} 원", +// style = MaterialTheme.typography.h4, +// fontWeight = FontWeight.Bold, +// color = if (lastPrice?.contains("-") ?: false) Color.Blue else Color.Red // ) -// // 최대 100개까지만 유지하여 성능 최적화 -// chartData = (chartData + newCandle).takeLast(100) +// Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray) +// } +// } +// // 통합된 트렌드 카드 배치 +// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { +// PeriodTrendCard("7일", daySummary, Modifier.weight(1f)) +// PeriodTrendCard("4주", weekSummary, Modifier.weight(1f)) +// PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f)) +// PeriodTrendCard("3년", yearSummary, Modifier.weight(1f)) +// } +// +// Spacer(modifier = Modifier.height(4.dp)) +// // [중앙] 캔들 차트 (Card 내부) +// Card( +// modifier = Modifier.fillMaxWidth().height(320.dp), +// backgroundColor = Color(0xFF121212) +// ) { +// if (isLoading) { +// Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) } // } else { -// // 같은 분 내에서는 기존 마지막 캔들만 업데이트 -// val updatedCandle = lastCandle.copy( -// stck_prpr = latestPrice, -// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr, -// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr +// CandleChart(data = chartData, modifier = Modifier.padding(16.dp)) +// } +// } +// +// Spacer(modifier = Modifier.height(4.dp)) +// +// +// +// // [하단] 실시간 체결 내역 및 주문 섹션 +// Row(modifier = Modifier.weight(1f)) { +// // 실시간 체결 리스트 +// Column(modifier = Modifier.weight(1f)) { +// Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) +// RealTimeTradeList(wsManager.tradeLogs) +// } +// +// Spacer(modifier = Modifier.width(12.dp)) +// +// // 주문 섹션 (인자 간소화) +// Column(modifier = Modifier.weight(0.6f)) { +// IntegratedOrderSection( +// stockCode = stockCode, +// stockName = stockName, +// isDomestic = isDomestic, +// currentPrice = lastPrice, +// holdingQuantity = holdingQuantity, +// tradeService = tradeService, +// onOrderSaved = onOrderSaved, +// onOrderResult = { msg, success -> +// resultMessage = msg +// isSuccess = success +// }, +// completeTradingDecision // ) -// chartData = chartData.dropLast(1) + updatedCandle // } // } // } - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - // [상단] 종목명 및 상태 메시지 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - StockHeader( - name = stockName, - code = stockCode, - isDomestic = isDomestic, - previousClose = previousClose, - openPrice = lastPrice, - resultMessage = resultMessage, - resultMessageClear = {resultMessage = ""}, - isSuccess = isSuccess - ) - - // 실시간 가격 표시 (WebSocket 데이터) - Column(horizontalAlignment = Alignment.End) { - Text( - text = "${lastPrice} 원", - style = MaterialTheme.typography.h4, - fontWeight = FontWeight.Bold, - color = if (lastPrice?.contains("-") ?: false) Color.Blue else Color.Red - ) - Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray) - } - } - // 통합된 트렌드 카드 배치 - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - PeriodTrendCard("7일", daySummary, Modifier.weight(1f)) - PeriodTrendCard("4주", weekSummary, Modifier.weight(1f)) - PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f)) - PeriodTrendCard("3년", yearSummary, Modifier.weight(1f)) - } - - Spacer(modifier = Modifier.height(4.dp)) - // [중앙] 캔들 차트 (Card 내부) - Card( - modifier = Modifier.fillMaxWidth().height(320.dp), - backgroundColor = Color(0xFF121212) - ) { - if (isLoading) { - Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) } - } else { - CandleChart(data = chartData, modifier = Modifier.padding(16.dp)) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - - - - // [하단] 실시간 체결 내역 및 주문 섹션 - Row(modifier = Modifier.weight(1f)) { - // 실시간 체결 리스트 - Column(modifier = Modifier.weight(1f)) { - Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) - RealTimeTradeList(wsManager.tradeLogs) - } - - Spacer(modifier = Modifier.width(12.dp)) - - // 주문 섹션 (인자 간소화) - Column(modifier = Modifier.weight(0.6f)) { - IntegratedOrderSection( - stockCode = stockCode, - stockName = stockName, - isDomestic = isDomestic, - currentPrice = lastPrice, - holdingQuantity = holdingQuantity, - tradeService = tradeService, - onOrderSaved = onOrderSaved, - onOrderResult = { msg, success -> - resultMessage = msg - isSuccess = success - }, - completeTradingDecision - ) - } - } - } -} - -@Composable -fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) { - Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) { - Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(label, fontSize = 10.sp, color = Color.Gray) - Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black) - } - } -} \ No newline at end of file +//} +// +//@Composable +//fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) { +// Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) { +// Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { +// Text(label, fontSize = 10.sp, color = Color.Gray) +// Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black) +// } +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/StockHeader.kt b/src/main/kotlin/ui/StockHeader.kt index 797325c..dc77429 100644 --- a/src/main/kotlin/ui/StockHeader.kt +++ b/src/main/kotlin/ui/StockHeader.kt @@ -1,74 +1,74 @@ -// src/main/kotlin/ui/StockHeader.kt -package ui - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun StockHeader( - name: String, - code: String, - isDomestic: Boolean, - previousClose: String, // 추가: 전일 종가 - openPrice: String, // 추가: 금일 시가 - resultMessage: String, - resultMessageClear : ()->Unit, - isSuccess: Boolean -) { - Column(modifier = Modifier.wrapContentWidth()) { - // [1] 알림 메시지 영역 (기존 동일) - if (resultMessage.isNotEmpty()) { - Surface( - color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), - modifier = Modifier.padding(bottom = 8.dp), - shape = RoundedCornerShape(4.dp), - onClick = { - resultMessageClear.invoke() - } - ) { - Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold) - } - } - - // [2] 종목명 및 정보 - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), - shape = RoundedCornerShape(4.dp) - ) { - Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) - } - Spacer(modifier = Modifier.width(8.dp)) - Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.width(6.dp)) - Text(text = "($code)", color = Color.Gray) - } - - // [3] 전일 종가 및 시가 정보 행 추가 - Row(modifier = Modifier.padding(top = 4.dp)) { - PriceSummaryItem("전일 종가", previousClose) - Spacer(modifier = Modifier.width(16.dp)) - PriceSummaryItem("금일 시가", openPrice) - } - } -} - -@Composable -fun PriceSummaryItem(label: String, price: String) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = label, fontSize = 11.sp, color = Color.Gray) - Spacer(modifier = Modifier.width(4.dp)) - val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price - Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium) - } -} \ No newline at end of file +//// src/main/kotlin/ui/StockHeader.kt +//package ui +// +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.material.* +//import androidx.compose.runtime.Composable +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.text.style.TextAlign +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +// +//@OptIn(ExperimentalMaterialApi::class) +//@Composable +//fun StockHeader( +// name: String, +// code: String, +// isDomestic: Boolean, +// previousClose: String, // 추가: 전일 종가 +// openPrice: String, // 추가: 금일 시가 +// resultMessage: String, +// resultMessageClear : ()->Unit, +// isSuccess: Boolean +//) { +// Column(modifier = Modifier.wrapContentWidth()) { +// // [1] 알림 메시지 영역 (기존 동일) +// if (resultMessage.isNotEmpty()) { +// Surface( +// color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), +// modifier = Modifier.padding(bottom = 8.dp), +// shape = RoundedCornerShape(4.dp), +// onClick = { +// resultMessageClear.invoke() +// } +// ) { +// Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold) +// } +// } +// +// // [2] 종목명 및 정보 +// Row(verticalAlignment = Alignment.CenterVertically) { +// Surface( +// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), +// shape = RoundedCornerShape(4.dp) +// ) { +// Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) +// } +// Spacer(modifier = Modifier.width(8.dp)) +// Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold) +// Spacer(modifier = Modifier.width(6.dp)) +// Text(text = "($code)", color = Color.Gray) +// } +// +// // [3] 전일 종가 및 시가 정보 행 추가 +// Row(modifier = Modifier.padding(top = 4.dp)) { +// PriceSummaryItem("전일 종가", previousClose) +// Spacer(modifier = Modifier.width(16.dp)) +// PriceSummaryItem("금일 시가", openPrice) +// } +// } +//} +// +//@Composable +//fun PriceSummaryItem(label: String, price: String) { +// Row(verticalAlignment = Alignment.CenterVertically) { +// Text(text = label, fontSize = 11.sp, color = Color.Gray) +// Spacer(modifier = Modifier.width(4.dp)) +// val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price +// Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/TradeLogRow.kt b/src/main/kotlin/ui/TradeLogRow.kt index 775c092..5acf3f3 100644 --- a/src/main/kotlin/ui/TradeLogRow.kt +++ b/src/main/kotlin/ui/TradeLogRow.kt @@ -1,63 +1,63 @@ -package ui - - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* - -import io.ktor.client.engine.cio.CIO -// 아래 두 import가 'delegate' 에러를 해결합니다. -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import model.AppConfig -import model.BalanceSummary -import model.RankingStock -import model.RealTimeTrade -import model.StockHolding -import model.TradeType -import network.KisTradeService -import util.MarketUtil - -@Composable -fun TradeLogRow(trade: RealTimeTrade) { - val color = when (trade.type) { - TradeType.BUY -> Color(0xFFE03E2D) - TradeType.SELL -> Color(0xFF0E62CF) - else -> Color.DarkGray - } - - // 대량 체결(예: 1000주 이상) 시 연한 배경색 강조 - val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000 - val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent - - Row( - modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center) - Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End) - Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End) - Text( - text = trade.volume, - modifier = Modifier.weight(1f), - fontSize = 12.sp, - fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal, - color = color, - textAlign = TextAlign.End - ) - } -} \ No newline at end of file +//package ui +// +// +//import androidx.compose.foundation.background +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 +//import androidx.compose.foundation.shape.RoundedCornerShape +//import androidx.compose.material.* +//import androidx.compose.runtime.* +// +//import io.ktor.client.engine.cio.CIO +//// 아래 두 import가 'delegate' 에러를 해결합니다. +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.setValue +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.text.style.TextAlign +//import androidx.compose.ui.text.style.TextOverflow +//import androidx.compose.ui.unit.dp +//import androidx.compose.ui.unit.sp +//import kotlinx.coroutines.launch +//import model.AppConfig +//import model.BalanceSummary +//import model.RankingStock +//import model.RealTimeTrade +//import model.StockHolding +//import model.TradeType +//import network.KisTradeService +//import util.MarketUtil +// +//@Composable +//fun TradeLogRow(trade: RealTimeTrade) { +// val color = when (trade.type) { +// TradeType.BUY -> Color(0xFFE03E2D) +// TradeType.SELL -> Color(0xFF0E62CF) +// else -> Color.DarkGray +// } +// +// // 대량 체결(예: 1000주 이상) 시 연한 배경색 강조 +// val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000 +// val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent +// +// Row( +// modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp), +// verticalAlignment = Alignment.CenterVertically +// ) { +// Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center) +// Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End) +// Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End) +// Text( +// text = trade.volume, +// modifier = Modifier.weight(1f), +// fontSize = 12.sp, +// fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal, +// color = color, +// textAlign = TextAlign.End +// ) +// } +//} \ No newline at end of file diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt index 5014fab..f7e4900 100644 --- a/src/main/kotlin/ui/TradingDecisionLog.kt +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -395,3 +395,33 @@ fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? = // } } } + +fun findLongestCommonSubstring(s1: String, s2: String): String { + if (s1.isEmpty() || s2.isEmpty()) return "" + + var longest = "" + // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임 + val reference = if (s1.length <= s2.length) s1 else s2 + val target = if (s1.length <= s2.length) s2 else s1 + + for (i in reference.indices) { + for (j in (i + longest.length + 1)..reference.length) { + val sub = reference.substring(i, j) + if (target.contains(sub)) { + if (sub.length > longest.length) { + longest = sub + } + } else { + // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출 + break + } + } + } + return longest +} + +fun getRemaining(original: String, common: String): String { + if (common.isEmpty()) return original + // 가장 처음 발견되는 공통 문자열을 한 번만 제거 + return original.replaceFirst(common, "").trim() +} \ No newline at end of file