// src/main/kotlin/ui/IntegratedOrderSection.kt package ui import AutoTradeItem import 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.min 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 util.MarketUtil import kotlin.math.min enum class InvestmentGrade( val displayName: String, val description: String, val shortWeight: Double = 0.0, val midWeight: Double = 0.0, val longWeight: Double = 0.0, val profitGuide: ConfigIndex, val buyGuide: ConfigIndex, ) { LEVEL_5_STRONG_RECOMMEND( displayName = "최상급 추천", description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천", shortWeight = 1.0, midWeight = 1.0, longWeight = 1.0, profitGuide = ConfigIndex.GRADE_5_PROFIT, buyGuide = ConfigIndex.GRADE_5_BUY, ), LEVEL_4_BALANCED_RECOMMEND( displayName = "균형 추천", description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천", shortWeight = 0.8, midWeight = 1.0, longWeight = 1.0, profitGuide = ConfigIndex.GRADE_4_PROFIT, buyGuide = ConfigIndex.GRADE_4_BUY, ), LEVEL_3_CAUTIOUS_RECOMMEND( displayName = "보수적 추천", description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함", shortWeight = 0.6, midWeight = 1.0, longWeight = 1.0, profitGuide = ConfigIndex.GRADE_3_PROFIT, buyGuide = ConfigIndex.GRADE_3_BUY, ), LEVEL_2_HIGH_RISK( displayName = "고위험 추천", description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자", shortWeight = 1.0, midWeight = 0.4, longWeight = 0.4, profitGuide = ConfigIndex.GRADE_2_PROFIT, buyGuide = ConfigIndex.GRADE_2_BUY, ), LEVEL_1_SPECULATIVE( displayName = "순수 공격적 선택", description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자", shortWeight = 1.0, midWeight = 0.2, longWeight = 0.2, profitGuide = ConfigIndex.GRADE_1_PROFIT, buyGuide = ConfigIndex.GRADE_1_BUY, ) } /** * 통합 주문 및 자동매매 설정 섹션 * * [수정 사항] * 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 서버에서 생성된 실제 주문번호 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 { 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) val 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.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 "profit" to 0.3, "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 calculatedQty = if (basePrice > 0) { (maxBudget / basePrice).toInt().coerceAtLeast(1) } else { 1 } // 5. 매수 실행 (계산된 finalMargin 전달) excuteTrade( willEnableAutoSell = true, orderQty = min(calculatedQty, KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX).toInt()).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", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달") } } when (completeTradingDecision?.decision) { "BUY" -> { append = buyWeight println("[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}") resultCheck(completeTradingDecision) } "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") else -> { append = 0.0 resultCheck(completeTradingDecision) println("[$stockCode] 관망 유지 resultCheck: ${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) // // 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() ) } ) } ) } }