// 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.sp import kotlinx.coroutines.launch import model.buyWeight import model.feesAndTaxRate import model.minimumNetProfit import network.KisTradeService import util.MarketUtil /** * 통합 주문 및 자동매매 설정 섹션 * * [수정 사항] * 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() ?: minimumNetProfit.toString()) } var stopLossRate by remember(monitoringItem) { mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5") } // 계산용 변수 val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0) val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?) { scope.launch { val tickSize = MarketUtil.getTickSize(basePrice) val oneTickLowerPrice = basePrice - tickSize // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) val finalPrice = if (orderPrice.isBlank()) { oneTickLowerPrice.toLong().toString() } else { orderPrice } println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") tradeService.postOrder(stockCode, orderQty, finalPrice, 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와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다. val effectiveProfitRate = maxOf((profitRate1 ?: pRate) + feesAndTaxRate, minimumNetProfit + feesAndTaxRate) // 4. 보정된 수익률을 적용하여 목표가 계산 val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.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("%.2f", 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)) { fun resultCheck(completeTradingDecision :TradingDecision) { println(""" corpName : ${completeTradingDecision.corpName} confidence : ${completeTradingDecision.confidence + append} shortPossible : ${completeTradingDecision.shortPossible() + append} profitPossible : ${completeTradingDecision.profitPossible()+ append} safePossible : ${completeTradingDecision.safePossible()+ append} """.trimIndent()) val weights = mapOf( "short" to 0.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 "profit" to 0.3, "safe" to 0.4 // 중장기 점수 비중 강화 ) // 2. 토탈 스코어 계산 val totalScore = (completeTradingDecision.shortPossible() * weights["short"]!!) + (completeTradingDecision.profitPossible() * weights["profit"]!!) + (completeTradingDecision.safePossible() * weights["safe"]!!) // 3. 매수 결정 문턱값 (예: 70점 이상이면 매수 가능) val MIN_PURCHASE_SCORE = 68.0 val HIGH_QUALITY_SCORE = 85.0 // 강력 추천 기준 if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence > MIN_CONFIDENCE) { // 4. 점수에 따른 가변 마진 적용 // 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용 val finalMargin = if (totalScore >= HIGH_QUALITY_SCORE) { println("💎 [우량주 포착] 토탈 스코어($totalScore)가 매우 높아 목표 마진을 3.0%로 상향합니다.") minimumNetProfit + (append * 1.5) } else { minimumNetProfit + append } println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") // 5. 매수 실행 (계산된 finalMargin 전달) excuteTrade( willEnableAutoSell = true, orderQty = "1", profitRate1 = finalMargin ) } else { println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)})가 기준치($MIN_PURCHASE_SCORE) 미달") } } 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) ) } // 수익률 시뮬레이션 표 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() ) } ) } ) } }