atrade/src/main/kotlin/ui/IntegratedOrderSection.kt
2026-02-19 15:47:31 +09:00

545 lines
26 KiB
Kotlin

// 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<String>) {
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()
)
}
)
}
)
}
}