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-14 15:42:26 +09:00
|
|
|
import androidx.compose.foundation.background
|
|
|
|
|
import androidx.compose.foundation.layout.*
|
|
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
|
|
|
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 kotlinx.coroutines.launch
|
|
|
|
|
import network.KisTradeService
|
|
|
|
|
|
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,
|
|
|
|
|
onOrderResult: (String, Boolean) -> Unit
|
|
|
|
|
) {
|
|
|
|
|
val scope = rememberCoroutineScope()
|
2026-01-19 17:09:37 +09:00
|
|
|
|
|
|
|
|
// 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리
|
|
|
|
|
var monitoringItem by remember(stockCode) {
|
|
|
|
|
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val isAutoSellEnabled = monitoringItem != null
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0")
|
|
|
|
|
}
|
|
|
|
|
var stopLossRate by remember(monitoringItem) {
|
|
|
|
|
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-3.0")
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
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-14 15:42:26 +09:00
|
|
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
value = orderQty,
|
|
|
|
|
onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it },
|
|
|
|
|
label = { Text("수량") },
|
|
|
|
|
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
|
|
|
|
)
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
|
|
|
|
2026-01-19 17:09:37 +09:00
|
|
|
// 실시간 AI 매도 감시 설정 카드
|
2026-01-14 15:42:26 +09:00
|
|
|
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
|
|
|
|
|
Column(modifier = Modifier.padding(8.dp)) {
|
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
2026-01-19 17:09:37 +09:00
|
|
|
Checkbox(
|
|
|
|
|
checked = isAutoSellEnabled,
|
|
|
|
|
onCheckedChange = { 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)
|
2026-01-14 15:42:26 +09:00
|
|
|
}
|
2026-01-19 17:09:37 +09:00
|
|
|
|
|
|
|
|
Row {
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
value = profitRate, onValueChange = { profitRate = it },
|
|
|
|
|
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp),
|
|
|
|
|
enabled = !isAutoSellEnabled
|
|
|
|
|
)
|
|
|
|
|
OutlinedTextField(
|
|
|
|
|
value = stopLossRate, onValueChange = { stopLossRate = it },
|
|
|
|
|
label = { Text("손절 %") }, modifier = Modifier.weight(1f),
|
|
|
|
|
enabled = !isAutoSellEnabled
|
|
|
|
|
)
|
2026-01-14 15:42:26 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
|
|
|
|
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 = {
|
|
|
|
|
scope.launch {
|
|
|
|
|
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
|
|
|
|
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
2026-01-19 17:09:37 +09:00
|
|
|
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
|
|
|
|
onOrderResult("주문 성공: $realOrderNo", true)
|
|
|
|
|
if (isAutoSellEnabled) {
|
|
|
|
|
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
|
|
|
|
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
|
|
|
|
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
|
|
|
|
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
|
|
|
|
code = stockCode,
|
|
|
|
|
name = stockName,
|
|
|
|
|
quantity = inputQty,
|
|
|
|
|
profitRate = pRate,
|
|
|
|
|
stopLossRate = sRate,
|
|
|
|
|
targetPrice = basePrice * (1 + pRate / 100.0),
|
|
|
|
|
stopLossPrice = basePrice * (1 + sRate / 100.0),
|
|
|
|
|
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
|
|
|
|
isDomestic = isDomestic
|
|
|
|
|
))
|
|
|
|
|
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
|
|
|
|
}
|
2026-01-14 15:42:26 +09:00
|
|
|
}
|
2026-01-19 17:09:37 +09:00
|
|
|
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
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) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|