atrade/src/main/kotlin/ui/IntegratedOrderSection.kt

427 lines
20 KiB
Kotlin
Raw Normal View History

2026-01-14 15:42:26 +09:00
// src/main/kotlin/ui/IntegratedOrderSection.kt
package ui
2026-01-19 17:09:37 +09:00
import AutoTradeItem
2026-01-23 17:05:09 +09:00
import TradingDecision
2026-01-22 16:21:18 +09:00
import androidx.compose.foundation.interaction.MutableInteractionSource
2026-01-14 15:42:26 +09:00
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
2026-01-22 16:21:18 +09:00
import androidx.compose.foundation.text.BasicTextField
2026-01-14 15:42:26 +09:00
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
2026-01-22 16:21:18 +09:00
import androidx.compose.ui.text.TextStyle
2026-01-14 15:42:26 +09:00
import androidx.compose.ui.text.font.FontWeight
2026-01-22 16:21:18 +09:00
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
2026-01-14 15:42:26 +09:00
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
2026-02-04 15:32:05 +09:00
import model.buyWeight
2026-02-04 14:52:09 +09:00
import model.feesAndTaxRate
import model.minimumNetProfit
2026-01-14 15:42:26 +09:00
import network.KisTradeService
2026-01-21 11:49:30 +09:00
import util.MarketUtil
2026-01-14 15:42:26 +09:00
2026-01-19 17:09:37 +09:00
/**
* 통합 주문 자동매매 설정 섹션
* * [수정 사항]
* 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지
* 2. 수량 입력 콤마 제거 Int 변환 예외 처리 적용
* 3. 매수 성공 반환받은 실제 주문번호(ODNO) DB에 저장하여 주문번호 중심 관리 구현
*/
2026-01-14 15:42:26 +09:00
@Composable
fun IntegratedOrderSection(
stockCode: String,
2026-01-19 17:09:37 +09:00
stockName: String,
isDomestic: Boolean,
2026-01-14 15:42:26 +09:00
currentPrice: String,
2026-01-19 17:09:37 +09:00
holdingQuantity: String,
2026-01-14 15:42:26 +09:00
tradeService: KisTradeService,
2026-01-21 11:49:30 +09:00
onOrderSaved: (String) -> Unit,
2026-01-23 17:05:09 +09:00
onOrderResult: (String, Boolean) -> Unit,
completeTradingDecision: TradingDecision?
2026-01-14 15:42:26 +09:00
) {
val scope = rememberCoroutineScope()
2026-01-19 17:09:37 +09:00
// 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리
var monitoringItem by remember(stockCode) {
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
}
2026-01-21 11:49:30 +09:00
var activeMonitoringItem by remember(stockCode) {
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
}
// 2. 체크박스의 '의도' 상태 (신규 매수 시 자동감시를 켤 것인지 여부)
// 감시 중인 아이템이 있으면 true, 없으면 사용자 선택에 따름
var willEnableAutoSell by remember(stockCode) {
mutableStateOf(activeMonitoringItem != null)
}
2026-01-19 17:09:37 +09:00
2026-01-23 17:05:09 +09:00
2026-01-19 17:09:37 +09:00
// UI 입력 상태
2026-01-14 15:42:26 +09:00
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
2026-01-19 17:09:37 +09:00
var orderQty by remember(holdingQuantity) {
// 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리)
val cleanQty = holdingQuantity.replace(",", "")
mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty)
}
2026-01-14 15:42:26 +09:00
2026-01-19 17:09:37 +09:00
var profitRate by remember(monitoringItem) {
2026-02-04 15:32:05 +09:00
mutableStateOf(monitoringItem?.profitRate?.toString() ?: minimumNetProfit.toString())
2026-01-19 17:09:37 +09:00
}
var stopLossRate by remember(monitoringItem) {
2026-02-04 14:52:09 +09:00
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5")
2026-01-19 17:09:37 +09:00
}
2026-01-14 15:42:26 +09:00
2026-01-19 17:09:37 +09:00
// 계산용 변수
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
2026-01-14 15:42:26 +09:00
2026-02-04 15:32:05 +09:00
fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?) {
2026-01-23 17:05:09 +09:00
scope.launch {
2026-02-04 14:52:09 +09:00
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")
2026-01-23 17:05:09 +09:00
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) {
2026-02-04 14:52:09 +09:00
// 1. 기본 설정값 파싱
2026-01-23 17:05:09 +09:00
val pRate = profitRate.toDoubleOrNull() ?: 0.0
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
2026-02-04 14:52:09 +09:00
// 2. 수수료 및 세금 보정치 설정 (국내 주식 기준 약 0.25% ~ 0.3%)
// 유관기관 수수료 및 매도세금을 고려하여 안전하게 0.3%로 잡거나, 필요시 더 높게 설정 가능합니다.
// 3. 실질 목표 수익률 계산
// 사용자가 입력한 pRate와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다.
2026-02-04 15:32:05 +09:00
val effectiveProfitRate = maxOf((profitRate1 ?: pRate) + feesAndTaxRate, minimumNetProfit + feesAndTaxRate)
2026-02-04 14:52:09 +09:00
// 4. 보정된 수익률을 적용하여 목표가 계산
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
2026-01-23 17:05:09 +09:00
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
2026-02-04 14:52:09 +09:00
// 5. DB 저장 (effectiveProfitRate를 저장하여 분석 시 실제 목표치를 확인 가능하게 함)
2026-01-23 17:05:09 +09:00
DatabaseFactory.saveAutoTrade(AutoTradeItem(
2026-02-04 14:52:09 +09:00
orderNo = realOrderNo,
2026-01-23 17:05:09 +09:00
code = stockCode,
name = stockName,
quantity = inputQty,
2026-02-04 14:52:09 +09:00
profitRate = effectiveProfitRate, // 보정된 수익률 저장
2026-01-23 17:05:09 +09:00
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
2026-02-04 14:52:09 +09:00
status = "PENDING_BUY",
2026-01-23 17:05:09 +09:00
isDomestic = isDomestic
))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo)
2026-02-04 14:52:09 +09:00
onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.2f", effectiveProfitRate)}%): $realOrderNo", true)
2026-01-23 17:05:09 +09:00
}
}
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
}
}
LaunchedEffect(completeTradingDecision) {
2026-02-03 18:07:18 +09:00
val MIN_CONFIDENCE = 70.0 // 최소 신뢰도
2026-02-04 14:52:09 +09:00
val MIN_SAFE_SCORE = 65.0 // 최소 중기 점수 (주봉/재무)
val MIN_POSSIBLE_SCORE = 55.0 // 최소 중기 점수 (주봉/재무)
val MIN_SHORT_SCORE = 60.0 // 최소 중기 점수 (주봉/재무)
var append = 0.0
2026-01-23 17:05:09 +09:00
if (completeTradingDecision != null &&
completeTradingDecision.stockCode.equals(stockCode)) {
2026-02-04 14:52:09 +09:00
2026-02-03 18:07:18 +09:00
fun resultCheck(completeTradingDecision :TradingDecision) {
println("""
2026-02-04 14:52:09 +09:00
corpName : ${completeTradingDecision.corpName}
confidence : ${completeTradingDecision.confidence + append}
shortPossible : ${completeTradingDecision.shortPossible() + append}
profitPossible : ${completeTradingDecision.profitPossible()+ append}
safePossible : ${completeTradingDecision.safePossible()+ append}
2026-02-03 18:07:18 +09:00
""".trimIndent())
2026-02-04 15:32:05 +09:00
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점 이상이면 매수 가능)
2026-02-04 17:49:21 +09:00
val MIN_PURCHASE_SCORE = 68.0
2026-02-04 15:32:05 +09:00
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
)
2026-02-03 18:07:18 +09:00
} else {
2026-02-04 15:32:05 +09:00
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)})가 기준치($MIN_PURCHASE_SCORE) 미달")
2026-02-03 18:07:18 +09:00
}
}
2026-01-23 17:05:09 +09:00
when (completeTradingDecision?.decision) {
2026-02-03 18:07:18 +09:00
"BUY" -> {
2026-02-04 15:32:05 +09:00
append = buyWeight
2026-02-03 18:07:18 +09:00
println("[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}")
resultCheck(completeTradingDecision)
}
2026-01-23 17:05:09 +09:00
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
2026-02-03 18:07:18 +09:00
else -> {
2026-02-04 14:52:09 +09:00
append = 0.0
2026-02-03 18:07:18 +09:00
resultCheck(completeTradingDecision)
println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}")
}
2026-01-23 17:05:09 +09:00
}
}
}
2026-01-14 15:42:26 +09:00
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
2026-01-19 17:09:37 +09:00
// 가격 및 수량 입력 필드
2026-01-22 16:21:18 +09:00
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
AutoResizeOutlinedTextField(
2026-01-14 15:42:26 +09:00
value = orderQty,
onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it },
label = { Text("수량") },
2026-01-22 16:21:18 +09:00
modifier = Modifier.weight(1f)
2026-01-14 15:42:26 +09:00
)
2026-01-22 16:21:18 +09:00
AutoResizeOutlinedTextField(
2026-01-14 15:42:26 +09:00
value = orderPrice,
onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it },
label = { Text("가격") },
placeholder = { Text("시장가 (${currentPrice})") },
modifier = Modifier.weight(1f)
)
}
2026-01-19 17:09:37 +09:00
// 수익률 시뮬레이션 표
if (basePrice > 0 && inputQty > 0) {
SimulationCard(basePrice, inputQty.toDouble())
2026-01-14 15:42:26 +09:00
}
2026-01-22 16:21:18 +09:00
Spacer(modifier = Modifier.height(4.dp))
2026-01-14 15:42:26 +09:00
2026-01-19 17:09:37 +09:00
// 실시간 AI 매도 감시 설정 카드
2026-01-14 15:42:26 +09:00
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
2026-01-22 16:21:18 +09:00
Column(modifier = Modifier.padding(4.dp)) {
2026-01-14 15:42:26 +09:00
Row(verticalAlignment = Alignment.CenterVertically) {
2026-01-19 17:09:37 +09:00
Checkbox(
2026-01-21 11:49:30 +09:00
checked = willEnableAutoSell,
2026-01-19 17:09:37 +09:00
onCheckedChange = { checked ->
2026-01-21 11:49:30 +09:00
willEnableAutoSell = checked
2026-01-19 17:09:37 +09:00
if (!checked) {
// [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지)
monitoringItem?.id?.let { dbId ->
DatabaseFactory.deleteAutoTrade(dbId)
monitoringItem = null
println("🗑️ 감시 해제: $stockName (ID: $dbId)")
}
} else {
// [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작
2026-01-21 11:49:30 +09:00
// 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)
// }
2026-01-19 17:09:37 +09:00
}
}
)
Text("실시간 AI 매도 감시 활성화", fontSize = 12.sp, fontWeight = FontWeight.Bold)
2026-01-14 15:42:26 +09:00
}
2026-01-19 17:09:37 +09:00
Row {
2026-01-22 16:21:18 +09:00
AutoResizeOutlinedTextField(
2026-01-19 17:09:37 +09:00
value = profitRate, onValueChange = { profitRate = it },
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp),
)
2026-01-22 16:21:18 +09:00
AutoResizeOutlinedTextField(
2026-01-19 17:09:37 +09:00
value = stopLossRate, onValueChange = { stopLossRate = it },
label = { Text("손절 %") }, modifier = Modifier.weight(1f),
)
2026-01-14 15:42:26 +09:00
}
}
}
2026-01-22 16:21:18 +09:00
Spacer(modifier = Modifier.height(4.dp))
2026-01-14 15:42:26 +09:00
2026-01-19 17:09:37 +09:00
// 매수 / 매도 실행 버튼
2026-01-14 15:42:26 +09:00
Row(modifier = Modifier.fillMaxWidth()) {
2026-01-19 17:09:37 +09:00
// 매수 버튼
2026-01-14 15:42:26 +09:00
Button(
onClick = {
2026-02-04 15:32:05 +09:00
excuteTrade(willEnableAutoSell, orderQty, profitRate.toDouble())
2026-01-14 15:42:26 +09:00
},
modifier = Modifier.weight(1f).padding(end = 4.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
) { Text("매수", color = Color.White) }
2026-01-19 17:09:37 +09:00
// 매도 버튼
2026-01-14 15:42:26 +09:00
Button(
2026-01-19 17:09:37 +09:00
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) }
}
},
2026-01-14 15:42:26 +09:00
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
) { Text("매도", color = Color.White) }
}
}
2026-01-23 17:05:09 +09:00
2026-01-14 15:42:26 +09:00
}
@Composable
2026-01-19 17:09:37 +09:00
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<String>) {
2026-01-14 15:42:26 +09:00
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray)
items.forEach { text ->
2026-01-19 17:09:37 +09:00
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))
2026-01-14 15:42:26 +09:00
}
}
2026-01-22 16:21:18 +09:00
}
@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()
)
}
)
}
)
}
2026-01-14 15:42:26 +09:00
}