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-10 15:08:52 +09:00
|
|
|
import model.MAX_BUDGET
|
2026-02-12 13:11:07 +09:00
|
|
|
import model.MIN_PURCHASE_SCORE
|
2026-02-12 15:31:34 +09:00
|
|
|
import model.RankingStock
|
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-02-12 15:31:34 +09:00
|
|
|
import service.AutoTradingManager
|
2026-01-21 11:49:30 +09:00
|
|
|
import util.MarketUtil
|
2026-01-14 15:42:26 +09:00
|
|
|
|
2026-02-09 15:32:31 +09:00
|
|
|
enum class InvestmentGrade(
|
|
|
|
|
val displayName: String,
|
|
|
|
|
val description: String,
|
|
|
|
|
val shortWeight: Double = 0.0,
|
|
|
|
|
val midWeight: Double = 0.0,
|
2026-02-10 15:08:52 +09:00
|
|
|
val longWeight: Double = 0.0,
|
|
|
|
|
val profitGuide: Double = 0.0,
|
2026-02-09 15:32:31 +09:00
|
|
|
) {
|
|
|
|
|
LEVEL_5_STRONG_RECOMMEND(
|
|
|
|
|
displayName = "최상급 추천",
|
|
|
|
|
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
|
|
|
|
|
shortWeight = 1.0,
|
|
|
|
|
midWeight = 1.0,
|
2026-02-10 15:08:52 +09:00
|
|
|
longWeight = 1.0,
|
|
|
|
|
profitGuide = 1.8,
|
2026-02-09 15:32:31 +09:00
|
|
|
),
|
|
|
|
|
LEVEL_4_BALANCED_RECOMMEND(
|
|
|
|
|
displayName = "균형 추천",
|
|
|
|
|
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
|
|
|
|
|
shortWeight = 0.8,
|
|
|
|
|
midWeight = 1.0,
|
2026-02-10 15:08:52 +09:00
|
|
|
longWeight = 1.0,
|
2026-02-12 15:31:34 +09:00
|
|
|
profitGuide = 1.3,
|
2026-02-09 15:32:31 +09:00
|
|
|
),
|
|
|
|
|
LEVEL_3_CAUTIOUS_RECOMMEND(
|
|
|
|
|
displayName = "보수적 추천",
|
|
|
|
|
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
|
|
|
|
|
shortWeight = 0.6,
|
|
|
|
|
midWeight = 1.0,
|
2026-02-10 15:08:52 +09:00
|
|
|
longWeight = 1.0,
|
2026-02-12 15:31:34 +09:00
|
|
|
profitGuide = 0.9,
|
2026-02-09 15:32:31 +09:00
|
|
|
),
|
|
|
|
|
LEVEL_2_HIGH_RISK(
|
|
|
|
|
displayName = "고위험 추천",
|
|
|
|
|
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
|
|
|
|
|
shortWeight = 1.0,
|
|
|
|
|
midWeight = 0.4,
|
2026-02-10 15:08:52 +09:00
|
|
|
longWeight = 0.4,
|
2026-02-12 15:31:34 +09:00
|
|
|
profitGuide = 0.7,
|
2026-02-09 15:32:31 +09:00
|
|
|
),
|
|
|
|
|
LEVEL_1_SPECULATIVE(
|
|
|
|
|
displayName = "순수 공격적 선택",
|
|
|
|
|
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
|
|
|
|
|
shortWeight = 1.0,
|
|
|
|
|
midWeight = 0.2,
|
2026-02-10 15:08:52 +09:00
|
|
|
longWeight = 0.2,
|
2026-02-12 15:31:34 +09:00
|
|
|
profitGuide = 0.5,
|
2026-02-09 15:32:31 +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-02-12 15:31:34 +09:00
|
|
|
var basePrice: Double = 0.0
|
|
|
|
|
LaunchedEffect(currentPrice) {
|
|
|
|
|
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
|
|
|
|
basePrice = curPriceNum
|
|
|
|
|
}
|
2026-01-19 17:09:37 +09:00
|
|
|
// 계산용 변수
|
2026-02-12 15:31:34 +09:00
|
|
|
|
|
|
|
|
|
2026-01-14 15:42:26 +09:00
|
|
|
|
2026-02-09 15:32:31 +09:00
|
|
|
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 ->
|
|
|
|
|
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
|
|
|
|
|
|
|
|
|
|
// LEVEL_4: 중기·장기 기본 준수, 단기까지 양호
|
|
|
|
|
midLongAvg >= 75.0 && shortAvg >= 70.0 ->
|
|
|
|
|
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
|
|
|
|
|
|
|
|
|
// LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형
|
|
|
|
|
midLongAvg >= 70.0 && shortAvg in 60.0..70.0 ->
|
|
|
|
|
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
|
|
|
|
|
|
|
|
|
// LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매
|
|
|
|
|
shortAvg >= 75.0 && midLongAvg < 65.0 ->
|
|
|
|
|
InvestmentGrade.LEVEL_2_HIGH_RISK
|
|
|
|
|
|
|
|
|
|
// LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함
|
|
|
|
|
shortAvg >= 70.0 && midLongAvg < 55.0 ->
|
|
|
|
|
InvestmentGrade.LEVEL_1_SPECULATIVE
|
|
|
|
|
|
|
|
|
|
// 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립)
|
|
|
|
|
else ->
|
|
|
|
|
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-05 14:26:02 +09:00
|
|
|
|
2026-02-09 15:32:31 +09:00
|
|
|
fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
|
2026-01-23 17:05:09 +09:00
|
|
|
scope.launch {
|
2026-02-04 14:52:09 +09:00
|
|
|
val tickSize = MarketUtil.getTickSize(basePrice)
|
2026-02-09 15:32:31 +09:00
|
|
|
val oneTickLowerPrice = basePrice - (tickSize * when(investmentGrade) {
|
2026-02-13 13:49:40 +09:00
|
|
|
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> 0
|
2026-02-12 15:31:34 +09:00
|
|
|
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> 1
|
|
|
|
|
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> 2
|
|
|
|
|
InvestmentGrade.LEVEL_2_HIGH_RISK -> 2
|
2026-02-13 13:49:40 +09:00
|
|
|
InvestmentGrade.LEVEL_1_SPECULATIVE -> 3
|
|
|
|
|
else -> 3
|
2026-02-09 15:32:31 +09:00
|
|
|
})
|
2026-02-04 14:52:09 +09:00
|
|
|
|
|
|
|
|
// 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-05 14:26:02 +09:00
|
|
|
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 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-13 13:49:40 +09:00
|
|
|
basePrice = completeTradingDecision.currentPrice
|
2026-02-12 15:31:34 +09:00
|
|
|
println("basePrice $basePrice")
|
2026-02-03 18:07:18 +09:00
|
|
|
fun resultCheck(completeTradingDecision :TradingDecision) {
|
2026-02-04 15:32:05 +09:00
|
|
|
val weights = mapOf(
|
|
|
|
|
"short" to 0.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
|
|
|
|
|
"profit" to 0.3,
|
|
|
|
|
"safe" to 0.4 // 중장기 점수 비중 강화
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
val totalScore =
|
2026-02-13 13:49:40 +09:00
|
|
|
((completeTradingDecision.shortPossible() + append) * weights["short"]!!) +
|
|
|
|
|
((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) +
|
|
|
|
|
((completeTradingDecision.safePossible() + append) * weights["safe"]!!)
|
2026-02-12 15:31:34 +09:00
|
|
|
|
2026-02-05 14:26:02 +09:00
|
|
|
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
|
2026-02-10 15:08:52 +09:00
|
|
|
var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
|
2026-02-13 13:49:40 +09:00
|
|
|
|
2026-02-10 15:08:52 +09:00
|
|
|
val finalMargin = minimumNetProfit * investmentGrade.profitGuide
|
2026-02-12 15:31:34 +09:00
|
|
|
println("""
|
|
|
|
|
사명 : ${completeTradingDecision.corpName}
|
|
|
|
|
신뢰도 : ${completeTradingDecision.confidence + append}
|
|
|
|
|
단기성 : ${completeTradingDecision.shortPossible() + append}
|
|
|
|
|
수익성 : ${completeTradingDecision.profitPossible()+ append}
|
|
|
|
|
안전성 : ${completeTradingDecision.safePossible()+ append}
|
|
|
|
|
${investmentGrade.displayName} : ${investmentGrade.description}
|
|
|
|
|
총점 : ${totalScore}
|
|
|
|
|
""".trimIndent())
|
2026-02-04 15:32:05 +09:00
|
|
|
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
|
2026-02-10 15:08:52 +09:00
|
|
|
|
2026-02-05 14:26:02 +09:00
|
|
|
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
|
2026-02-12 15:31:34 +09:00
|
|
|
|
2026-02-05 14:26:02 +09:00
|
|
|
val calculatedQty = if (basePrice > 0) {
|
|
|
|
|
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
|
|
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
}
|
2026-02-04 15:32:05 +09:00
|
|
|
// 5. 매수 실행 (계산된 finalMargin 전달)
|
|
|
|
|
excuteTrade(
|
|
|
|
|
willEnableAutoSell = true,
|
2026-02-05 14:26:02 +09:00
|
|
|
orderQty = calculatedQty.toString(),
|
|
|
|
|
profitRate1 = finalMargin,
|
2026-02-10 15:08:52 +09:00
|
|
|
investmentGrade = investmentGrade,
|
2026-02-04 15:32:05 +09:00
|
|
|
)
|
2026-02-03 18:07:18 +09:00
|
|
|
|
2026-02-13 13:49:40 +09:00
|
|
|
} else if(totalScore >= (MIN_PURCHASE_SCORE * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) {
|
2026-02-12 15:31:34 +09:00
|
|
|
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
|
|
|
|
|
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
|
|
|
|
|
} else {
|
|
|
|
|
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달")
|
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-02-05 14:26:02 +09:00
|
|
|
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
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
|
|
|
}
|