This commit is contained in:
2026-01-19 17:09:37 +09:00
parent bdc268e325
commit 91f9e4ee9a
20 changed files with 786 additions and 974 deletions
+42 -38
View File
@@ -1,6 +1,7 @@
// src/main/kotlin/ui/ActiveTradeRow.kt
package ui
import AutoTradeItem
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -13,20 +14,21 @@ 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 model.ActiveTradeItem
import model.ActiveTradeType
@Composable
fun ActiveTradeRow(
item: ActiveTradeItem,
onCancelClick: (String) -> Unit = {}, // 미체결 취소
item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
onClick: () -> Unit
) {
val isMonitoring = item.type == ActiveTradeType.MONITORING
// 상태에 따른 배경색 설정 (미체결은 연노랑으로 강조)
val backgroundColor = if (isMonitoring) Color.White else Color(0xFFFFF9C4)
val badgeColor = if (isMonitoring) Color(0xFF0E62CF) else Color(0xFFE03E2D)
// 상태에 따른 UI 구성 요소 정의
val (statusText, statusColor, backgroundColor) = when (item.status) {
"PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑
"MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑
"SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강
"COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색
else -> Triple("알 수 없음", Color.Black, Color.White)
}
Card(
modifier = Modifier
@@ -39,65 +41,67 @@ fun ActiveTradeRow(
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
verticalAlignment = Alignment.CenterVertically
) {
// 좌측 정보 영역
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// 상태 배지 (자동감시 / 미체결)
// 상태 배지 표시
Surface(
color = badgeColor,
shape = RoundedCornerShape(4.dp)
color = statusColor,
shape = RoundedCornerShape(2.dp),
modifier = Modifier.padding(end = 6.dp)
) {
Text(
text = if (isMonitoring) "자동감시" else "미체결",
text = statusText,
color = Color.White,
fontSize = 10.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
fontSize = 9.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = item.name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
maxLines = 1
)
}
// 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시)
val detailText = when (item.status) {
"PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
"MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
else -> "주문번호: ${item.orderNo}"
}
Text(
text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}",
text = "${item.code} | $detailText",
fontSize = 11.sp,
color = Color.Gray
)
}
// 우측 액션 영역
// 우측 액션 및 수량 영역
Column(horizontalAlignment = Alignment.End) {
if (!isMonitoring) {
// 미체결인 경우 취소 버튼 표시
if (item.status == "PENDING_BUY" || item.status == "SELLING") {
// 진행 중인 주문인 경우 취소 버튼 노출
Button(
onClick = { onCancelClick(item.id) },
onClick = { onCancelClick() },
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.height(28.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
) {
Text("취소", fontSize = 11.sp)
}
Text(
text = "${item.quantity}",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFE03E2D)
)
} else {
// 자동감시 중인 경우 상태 텍스트 표시
Text(
text = "감시중",
fontSize = 12.sp,
color = badgeColor,
fontWeight = FontWeight.Medium
)
}
Text(
text = "${item.quantity}",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = statusColor
)
}
}
}
+17 -37
View File
@@ -15,51 +15,35 @@ 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 model.ActiveTradeItem
import model.ActiveTradeType
import model.toAutoTradeItem
import network.KisTradeService
// src/main/kotlin/ui/AutoTradeSection.kt
@Composable
fun AutoTradeSection(
isDomestic: Boolean,
tradeService: KisTradeService,
refreshTrigger: Int, // 갱신 트리거 추가
onRefresh: () -> Unit,
onItemSelect: (ActiveTradeItem) -> Unit
onItemSelect: (AutoTradeItem) -> Unit,
onItemCancel: (AutoTradeItem) -> Unit
) {
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
var combinedList by remember { mutableStateOf(emptyList<ActiveTradeItem>()) }
var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
// refreshTrigger가 바뀔 때마다 실행됨
LaunchedEffect(refreshTrigger) {
// 1. DB에서 감시 중인 종목 로드
val monitoringItems = DatabaseFactory.getActiveAutoTrades().map {
ActiveTradeItem(
id = it.code,
code = it.code,
name = it.name,
type = ActiveTradeType.MONITORING,
price = it.targetPrice,
quantity = "-",
isDomestic = it.isDomestic
)
}
val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
// 2. KIS API에서 미체결 주문 로드
val unfilledItems = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()).map {
ActiveTradeItem(
id = it.ord_no,
code = it.pdno,
name = it.prdt_name,
type = ActiveTradeType.UNFILLED,
price = it.ord_unpr.toDouble(),
quantity = it.rmnd_qty,
isDomestic = true
)
}
// 2. DB에서 로컬 감시 데이터 가져오기
val localTrades = DatabaseFactory.getActiveAutoTrades()
combinedList = monitoringItems + unfilledItems
// 3. 동기화 로직: 서버에 없는 주문번호를 가진 로컬 데이터는 EXPIRED로 표시
tradeList = localTrades.map { local ->
if (local.status != "COMPLETED" && serverUnfilled.none { it.orderNo == local.orderNo }) {
local.copy(status = "EXPIRED")
} else local
}
}
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
@@ -84,15 +68,11 @@ fun AutoTradeSection(
}
}
LazyColumn {
items(combinedList) { item ->
items(tradeList) { item ->
ActiveTradeRow(
item = item,
onCancelClick = { orderNo ->
// tradeService.cancelOrder(orderNo, item.code) 호출 로직
},
onClick = {
onItemSelect(item) // 상세 화면 전환용 콜백
}
onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
onClick = { onItemSelect(item) }
)
}
}
+116 -80
View File
@@ -1,80 +1,116 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.CandleData
import model.RankingStock
import model.StockHolding
import network.KisTradeService
import network.KisWebSocketManager
import kotlin.collections.isNotEmpty
@Composable
fun AutoTradeSettingCard(stockCode: String, currentPrice: String) {
var profitRate by remember { mutableStateOf("5.0") }
var stopLossRate by remember { mutableStateOf("-3.0") }
var isEnabled by remember { mutableStateOf(false) }
Card(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
backgroundColor = Color(0xFFF8F9FA) // detail.html의 order-box 배경색 참고
) {
Column(modifier = Modifier.padding(12.dp)) {
Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = profitRate,
onValueChange = { profitRate = it },
label = { Text("익절 %") },
modifier = Modifier.weight(1f).padding(end = 4.dp),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
)
OutlinedTextField(
value = stopLossRate,
onValueChange = { stopLossRate = it },
label = { Text("손절 %") },
modifier = Modifier.weight(1f),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { isEnabled = !isEnabled },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF)
)
) {
Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White)
}
}
}
}
//package ui
//
//import AutoTradeItem
//import androidx.compose.foundation.background
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material.*
//import androidx.compose.runtime.*
//import io.ktor.client.engine.cio.CIO
//// 아래 두 import가 'delegate' 에러를 해결합니다.
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.setValue
//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.text.style.TextAlign
//import androidx.compose.ui.text.style.TextOverflow
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.unit.sp
//import kotlinx.coroutines.delay
//import kotlinx.coroutines.launch
//import model.AppConfig
//import model.BalanceSummary
//import model.CandleData
//import model.RankingStock
//import model.StockHolding
//import network.KisTradeService
//import network.KisWebSocketManager
//import kotlin.collections.isNotEmpty
//
//
//@Composable
//fun AutoTradeSettingCard(
// stockCode: String,
// stockName: String, // 종목명 추가
// currentPrice: String,
// isDomestic: Boolean = true
//) {
// var profitRate by remember { mutableStateOf("5.0") }
// var stopLossRate by remember { mutableStateOf("-3.0") }
//
// // DB에서 현재 감시 중인지 확인
// var isEnabled by remember(stockCode) {
// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null)
// }
//
// Card(
// elevation = 4.dp,
// shape = RoundedCornerShape(8.dp),
// backgroundColor = Color(0xFFF8F9FA)
// ) {
// Column(modifier = Modifier.padding(12.dp)) {
// Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
// Spacer(modifier = Modifier.height(8.dp))
//
// Row(verticalAlignment = Alignment.CenterVertically) {
// OutlinedTextField(
// value = profitRate,
// onValueChange = { profitRate = it },
// label = { Text("익절 %") },
// modifier = Modifier.weight(1f).padding(end = 4.dp),
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
// )
// OutlinedTextField(
// value = stopLossRate,
// onValueChange = { stopLossRate = it },
// label = { Text("손절 %") },
// modifier = Modifier.weight(1f),
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
// )
// }
//
// Spacer(modifier = Modifier.height(8.dp))
//
// Button(
// onClick = {
// if (!isEnabled) {
// // 자동 매매 시작: DB 저장
// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
// val target = curPriceNum * (1 + profitRate.toDouble() / 100.0)
// val stopLoss = curPriceNum * (1 + stopLossRate.toDouble() / 100.0)
//
// DatabaseFactory.saveAutoTrade(
// AutoTradeItem(
// code = stockCode,
// name = stockName,
// targetPrice = target,
// stopLossPrice = stopLoss,
// status = "MONITORING",
// isDomestic = isDomestic
// )
// )
// // [중요] 웹소켓 실시간 감시 등록 로직이 이곳에 호출되어야 함
// // KisWebSocketManager.subscribe(stockCode)
// isEnabled = true
// } else {
// // 자동 매매 중단: DB 삭제
// DatabaseFactory.deleteAutoTrade(stockCode)
// // KisWebSocketManager.unsubscribe(stockCode)
// isEnabled = false
// }
// },
// modifier = Modifier.fillMaxWidth(),
// colors = ButtonDefaults.buttonColors(
// backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF)
// )
// ) {
// Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White)
// }
// }
// }
//}
+51 -17
View File
@@ -6,8 +6,11 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.filled.Refresh
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
@@ -17,13 +20,15 @@ import network.KisTradeService
@Composable
fun BalanceSection(
tradeService: KisTradeService,
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
refreshTrigger: Int, // 갱신 트리거 추가
onRefresh: () -> Unit,
onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit
) {
var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
var isLoading by remember { mutableStateOf(false) }
// 화면 진입 시 및 갱신 시 데이터 로드
LaunchedEffect(Unit) {
LaunchedEffect(refreshTrigger) {
isLoading = true
tradeService.fetchIntegratedBalance().onSuccess {
balanceData = it
@@ -34,13 +39,30 @@ fun BalanceSection(
}
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "나의 자산",
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "나의 자산",
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// 강제 갱신 버튼
IconButton(
onClick = onRefresh,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
contentDescription = "새로고침",
tint = Color(0xFF0E62CF),
modifier = Modifier.size(18.dp)
)
}
}
// 1. 자산 요약 카드
BalanceSummaryCard(balanceData)
@@ -62,7 +84,7 @@ fun BalanceSection(
LazyColumn(modifier = Modifier.weight(1f, true)) {
items(balanceData?.holdings ?: emptyList()) { holding ->
UnifiedStockItemRow(holding) {
onStockSelect(holding.code, holding.name, holding.isDomestic)
onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
}
}
}
@@ -78,6 +100,7 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
modifier = Modifier.fillMaxWidth(),
backgroundColor = androidx.compose.ui.graphics.Color.White
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
Text(
@@ -103,6 +126,9 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
@Composable
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0
val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
elevation = 1.dp
@@ -126,19 +152,27 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
}
Spacer(Modifier.width(4.dp))
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
Text(
"매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
fontSize = 11.sp, color = Color.Gray
)
}
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
Text("${holding.currentPrice}")
Column(horizontalAlignment = Alignment.End) {
Text("${holding.currentPrice}", fontWeight = FontWeight.Bold)
// 손익분기점 표시 추가
Text(
"손익분기: ${String.format("%,.0f", breakEvenPrice)}",
fontSize = 10.sp, color = Color(0xFF666666)
)
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%",
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
else androidx.compose.ui.graphics.Color.DarkGray,
fontSize = 12.sp
text = "${if (rate > 0) "+" else ""}${holding.profitRate}%",
color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
+63 -55
View File
@@ -12,87 +12,80 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import model.KisSession
import model.StockBasicInfo
import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil
@Composable
fun DashboardScreen() {
val tradeService = remember { KisTradeService() }
val wsManager = remember { KisWebSocketManager() }
val config = KisSession.config
val scope = rememberCoroutineScope()
// 데이터 갱신을 위한 트리거 상태
var refreshTrigger by remember { mutableStateOf(0) }
// 전역 상태: 현재 선택된 종목 정보
var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") }
var isDomestic by remember { mutableStateOf(true) }
var selectedStockQuantity by remember { mutableStateOf("0") }
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
LaunchedEffect(Unit) {
// 1. 웹소켓 연결
wsManager.connect()
// 2. 체결 통보 콜백 설정 (매수 성공 시 감시 시작)
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
if (isBuy) {
// [매수 체결 시] DB에 감시 데이터 저장
// 주의: targetPrice와 stopLossPrice는 이전에 설정된 값을 가져오거나
// 임시 상태값에서 가져와야 함 (여기선 예시로 현재가의 +5%, -3% 설정)
val execPrice = price.toDoubleOrNull() ?: 0.0
DatabaseFactory.saveAutoTrade(
AutoTradeItem(
code = code,
name = "", // 필요 시 종목명 매핑
targetPrice = execPrice * 1.05,
stopLossPrice = execPrice * 0.97,
status = "MONITORING",
isDomestic = true
)
)
println("📝 매수 체결로 인한 자동 감시 등록: $code")
} else {
// [매도 체결 시] 감시 종료 및 DB 삭제
DatabaseFactory.deleteAutoTrade(code)
println("✅ 매도 체결로 인한 감시 종료: $code")
}
// 2. [기동 시 동기화 시나리오]
scope.launch {
// (1) 서버 미체결 내역 로드
val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
val serverOrderNos = serverOrders.map { it.ord_no }
// (2) DB 상태 대조 및 EXPIRED 전환
DatabaseFactory.syncWithServer(serverOrderNos)
// (3) 활성 감시 종목 구독 재개
val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
val monitoringCodes = monitoringTrades.map { it.code }.toSet()
wsManager.updateSubscriptions(monitoringCodes)
refreshTrigger++
}
// 3. 목표가 도달 콜백 설정 (자동 매도 실행)
wsManager.onTargetReached = { code, price, isProfit ->
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
scope.launch {
println("🚀 목표가 도달! 자동 매도 주문 실행: $code (이유: ${if(isProfit) "익절" else "손절"})")
// 실제 매도 주문 API 호출
tradeService.postOrder(
stockCode = code,
qty = "1", // 실제론 보유 수량을 가져와야 함
price = "0", // 시장가 매도
isBuy = false
).onSuccess {
// 매도 주문 성공 시 로그 기록
DatabaseFactory.saveTradeLog(
code, "", "매도", price, 1,
if(isProfit) "AI 익절 조건 달성" else "AI 손절 조건 달성"
)
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
if (dbItem != null) {
when (dbItem.status) {
TradeStatus.PENDING_BUY -> {
// 매수 주문 체결 -> 감시(MONITORING)로 전환 및 익절 주문 발주
// 여기서 익절 주문 후 받은 신규 주문번호를 DB에 갱신
// updateStatusAndOrderNo(dbItem.id!!, TradeStatus.MONITORING, newSellOrderNo)
}
TradeStatus.SELLING -> {
// 매도(손절/익절) 주문 체결 -> COMPLETED
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
}
}
refreshTrigger++
}
}
refreshTrigger++
}
if (config.htsId.isNotEmpty()) {
wsManager.subscribeExecution(config.htsId)
println("📡 HTS ID(${config.htsId})로 체결 통보 구독을 시작합니다.")
}
}
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
BalanceSection(tradeService) { code, name, isDom ->
BalanceSection(tradeService,
onRefresh = { refreshTrigger++ },
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
selectedStockCode = code
selectedStockName = name
isDomestic = isDom
selectedStockQuantity = qty
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
}
}
@@ -105,6 +98,7 @@ fun DashboardScreen() {
StockDetailSection(
stockCode = selectedStockCode,
stockName = selectedStockName,
holdingQuantity = selectedStockQuantity,
isDomestic = isDomestic,
tradeService = tradeService,
wsManager = wsManager
@@ -119,19 +113,33 @@ fun DashboardScreen() {
VerticalDivider()
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
AutoTradeSection(
isDomestic = isDomestic,
tradeService = tradeService,
onRefresh = { refreshTrigger++ },
refreshTrigger = refreshTrigger // 트리거 전달
) { item ->
selectedStockCode = item.code
selectedStockName = item.name
isDomestic = item.isDomestic
}
refreshTrigger = refreshTrigger , // 트리거 전달
onItemCancel = { item ->
scope.launch {
tradeService.cancelOrder(item.orderNo,item.code).onSuccess {
refreshTrigger++
}
}
},
onItemSelect = { item ->
selectedStockCode = item.code
selectedStockName = item.name
isDomestic = item.isDomestic
})
}
VerticalDivider()
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
MarketSection(tradeService) { code, name, isDom ->
val info = StockBasicInfo(
code = code,
name = name,
isDomestic = isDom
)
selectedStockInfo = info
selectedStockCode = code
selectedStockName = name
isDomestic = isDom
+147 -52
View File
@@ -1,6 +1,7 @@
// src/main/kotlin/ui/IntegratedOrderSection.kt
package ui
import AutoTradeItem
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -15,29 +16,56 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import network.KisTradeService
/**
* 통합 주문 및 자동매매 설정 섹션
* * [수정 사항]
* 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지
* 2. 수량 입력 시 콤마 제거 및 Int 변환 예외 처리 적용
* 3. 매수 성공 시 반환받은 실제 주문번호(ODNO)를 DB에 저장하여 주문번호 중심 관리 구현
*/
@Composable
fun IntegratedOrderSection(
stockCode: String,
stockName: String,
isDomestic: Boolean,
currentPrice: String,
holdingQuantity: String,
tradeService: KisTradeService,
onOrderResult: (String, Boolean) -> Unit
) {
val scope = rememberCoroutineScope()
var orderQty by remember { mutableStateOf("1") }
// 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리
var monitoringItem by remember(stockCode) {
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
}
val isAutoSellEnabled = monitoringItem != 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 isAutoSellEnabled by remember { mutableStateOf(false) }
var profitRate by remember { mutableStateOf("5.0") }
var stopLossRate by remember { mutableStateOf("-3.0") }
var profitRate by remember(monitoringItem) {
mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0")
}
var stopLossRate by remember(monitoringItem) {
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-3.0")
}
val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0
val qty = orderQty.toDoubleOrNull() ?: 0.0
// 계산용 변수
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// 1. 가격 및 수량 입력
// 가격 및 수량 입력 필드
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
OutlinedTextField(
value = orderQty,
@@ -54,71 +82,121 @@ fun IntegratedOrderSection(
)
}
// 2. 수익률 시뮬레이션 표 (신규 추가)
if (basePrice > 0 && qty > 0) {
Text("익절/손절 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 11.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 4.dp))
Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) {
Column(modifier = Modifier.padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%"), true)
SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }, false)
SimulationColumn("예상수령액", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate ->
val sellPrice = basePrice * rate
val totalAmount = sellPrice * qty
val netAmount = totalAmount * (1 - 0.0022) // 수수료+세금 약 0.22% 차감
String.format("%,d", netAmount.toLong())
}, false)
}
}
}
// 수익률 시뮬레이션 표
if (basePrice > 0 && inputQty > 0) {
SimulationCard(basePrice, inputQty.toDouble())
}
Spacer(modifier = Modifier.height(12.dp))
// 3. 자동 매도 옵션
// 실시간 AI 매도 감시 설정 카드
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
Column(modifier = Modifier.padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isAutoSellEnabled, onCheckedChange = { isAutoSellEnabled = it })
Text("매수 체결 시 자동 매도 감시 시작", fontSize = 12.sp)
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)
}
if (isAutoSellEnabled) {
Row {
OutlinedTextField(
value = profitRate, onValueChange = { profitRate = it },
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp)
)
OutlinedTextField(
value = stopLossRate, onValueChange = { stopLossRate = it },
label = { Text("손절 %") }, modifier = Modifier.weight(1f)
)
}
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
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// 4. 매수/매도 버튼
// 매수 / 매도 실행 버튼
Row(modifier = Modifier.fillMaxWidth()) {
// 매수 버튼
Button(
onClick = {
scope.launch {
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess {
onOrderResult(it, true)
if (isAutoSellEnabled) { /* 자동매도 등록 로직 호출 */ }
.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)
}
}
.onFailure { onOrderResult(it.message ?: "에러", false) }
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
}
},
modifier = Modifier.weight(1f).padding(end = 4.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
) { Text("매수", color = Color.White) }
// 매도 버튼
Button(
onClick = { /* 매도 로직동일 */ },
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) }
@@ -127,16 +205,33 @@ fun IntegratedOrderSection(
}
@Composable
fun SimulationColumn(title: String, items: List<String>, isHeader: Boolean) {
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 ->
Text(
text = text,
fontSize = 11.sp,
color = if (text.contains("+")) Color(0xFFE03E2D) else if (text.contains("-")) Color(0xFF0E62CF) else Color.Black,
modifier = Modifier.padding(vertical = 1.dp)
)
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))
}
}
}
-115
View File
@@ -1,115 +0,0 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.StockHolding
import network.KisTradeService
@Composable
fun MyStockList(
holdings: List<StockHolding>,
onSelect: (code: String, name: String) -> Unit
) {
if (holdings.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("보유 종목이 없습니다.", color = Color.Gray, style = MaterialTheme.typography.body2)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(holdings) { stock ->
MyStockItemRow(stock) {
onSelect(stock.pdno, stock.prdt_name)
}
}
}
}
}
@Composable
fun MyStockItemRow(
stock: StockHolding,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = 1.dp,
shape = RoundedCornerShape(4.dp),
backgroundColor = Color.White
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. 종목명 및 코드
Column(modifier = Modifier.weight(1f)) {
Text(
text = stock.prdt_name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stock.pdno,
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// 2. 수익률 배지 (웹 소스 컬러 적용)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = when {
rate > 0 -> Color(0xFFE03E2D) // 매수색
rate < 0 -> Color(0xFF0E62CF) // 매도색
else -> Color.DarkGray
}
Column(horizontalAlignment = Alignment.End) {
Surface(
color = color.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rate > 0) "+" else ""}${stock.evlu_pfls_rt}%",
color = color,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
Text(
text = "${stock.prpr}",
style = MaterialTheme.typography.caption,
color = Color.DarkGray
)
}
}
}
}
-93
View File
@@ -1,93 +0,0 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.CandleData
import model.RankingStock
import model.StockHolding
import network.KisTradeService
import network.KisWebSocketManager
import kotlin.collections.isNotEmpty
@Composable
fun OrderSection(
stockCode: String,
currentPrice: String,
onOrderResult: (String, Boolean) -> Unit
) {
val scope = rememberCoroutineScope()
val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전
var orderQty by remember { mutableStateOf("1") }
var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가
Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) {
Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp)
OutlinedTextField(
value = orderQty,
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
label = { Text("수량") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = if(orderPrice == "0") "시장가" else orderPrice,
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
label = { Text("가격") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
scope.launch {
// KisSession을 사용하는 postOrder 호출
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true)
.onSuccess { onOrderResult(it, true) }
.onFailure { onOrderResult(it.message ?: "에러", false) }
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
) {
Text("매수", color = Color.White)
}
Button(
onClick = {
scope.launch {
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false)
.onSuccess { onOrderResult(it, true) }
.onFailure { onOrderResult(it.message ?: "에러", false) }
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
) {
Text("매도", color = Color.White)
}
}
}
-152
View File
@@ -1,152 +0,0 @@
//package ui
//
//
//import androidx.compose.foundation.BorderStroke
//import androidx.compose.foundation.background
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
//import androidx.compose.foundation.lazy.itemsIndexed
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material.*
//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
//import androidx.compose.runtime.*
//import io.ktor.client.engine.cio.CIO
//// 아래 두 import가 'delegate' 에러를 해결합니다.
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.setValue
//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.text.style.TextOverflow
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.unit.sp
//import kotlinx.coroutines.launch
//import model.AppConfig
//import model.BalanceSummary
//import model.RankingStock
//import model.RankingType
//import model.StockHolding
//import network.KisTradeService
//import util.MarketUtil
//
//@Composable
//fun RecommendationTabs(
// config: AppConfig,
// token: String,
// onSelect: (String, String) -> Unit
//) {
// var isDomestic by remember { mutableStateOf(true) }
// var selectedType by remember { mutableStateOf(RankingType.RISE) }
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
//
// val tradeService = remember { KisTradeService(config.isSimulation) }
// val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
// var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
//
// // 데이터 로드 로직
// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
// errorMessage = null // 로딩 시작 시 에러 초기화
// if (isDomestic) {
// if (isKoreaOpen) {
// tradeService.fetchMarketRanking(token, config, selectedType, true)
// .onSuccess { rankingList = it.take(20) }
// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
// } else {
// tradeService.fetchDomesticPreviousDayRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
// }
// } else {
// tradeService.fetchOverseasRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
// }
// }
//
// Column(modifier = Modifier.fillMaxSize()) {
// // [1] 국내/해외 전환 버튼 (항상 노출)
// Row(Modifier.fillMaxWidth().padding(8.dp)) {
// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
// Spacer(Modifier.width(8.dp))
// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
// }
//
// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
// ScrollableTabRow(
// selectedTabIndex = selectedType.ordinal,
// edgePadding = 8.dp,
// backgroundColor = Color.White,
// indicator = { tabPositions ->
// TabRowDefaults.Indicator(
// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
// )
// }
// ) {
// RankingType.values().forEach { type ->
// Tab(
// selected = selectedType == type,
// onClick = { selectedType = type },
// text = { Text(type.title, fontSize = 12.sp) }
// )
// }
// }
//
// // [3] 장외 시간 안내 바
// if (isDomestic && !isKoreaOpen) {
// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
// Text(
// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
// fontSize = 11.sp, modifier = Modifier.padding(8.dp)
// )
// }
// }
//
// // [4] 추천 리스트 영역
// Box(modifier = Modifier.weight(1f)) {
// if (errorMessage != null) {
// // 에러 발생 시 안내
// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
// Text(errorMessage!!, color = Color.Gray)
// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
// }
// } else if (rankingList.isEmpty()) {
// // 로딩 중
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// CircularProgressIndicator()
// }
// } else {
// // [성공] 리스트 노출
// LazyColumn {
// itemsIndexed(rankingList) { index, stock ->
// RankingItemRow(index, stock, isDomestic, selectedType) {
// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
// }
// }
// }
// }
// }
// }
//}
//
//@Composable
//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
// OutlinedButton(
// onClick = onClick,
// colors = ButtonDefaults.outlinedButtonColors(
// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
// ),
// modifier = Modifier.height(36.dp),
// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
// ) {
// Text(
// text = title,
// color = if (isSelected) activeColor else Color.Gray,
// fontSize = 12.sp,
// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
// )
// }
//}
+5
View File
@@ -38,6 +38,7 @@ import kotlin.collections.isNotEmpty
fun StockDetailSection(
stockCode: String,
stockName: String,
holdingQuantity : String,
isDomestic: Boolean,
tradeService: KisTradeService,
wsManager: KisWebSocketManager
@@ -153,6 +154,7 @@ fun StockDetailSection(
previousClose = previousClose,
openPrice = openPrice,
resultMessage = resultMessage,
resultMessageClear = {resultMessage = ""},
isSuccess = isSuccess
)
@@ -213,7 +215,10 @@ fun StockDetailSection(
Column(modifier = Modifier.weight(0.6f)) {
IntegratedOrderSection(
stockCode = stockCode,
stockName = stockName,
isDomestic = isDomestic,
currentPrice = wsManager.currentPrice.value,
holdingQuantity = holdingQuantity,
tradeService = tradeService,
onOrderResult = { msg, success ->
resultMessage = msg
+7 -2
View File
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun StockHeader(
name: String,
@@ -21,6 +22,7 @@ fun StockHeader(
previousClose: String, // 추가: 전일 종가
openPrice: String, // 추가: 금일 시가
resultMessage: String,
resultMessageClear : ()->Unit,
isSuccess: Boolean
) {
Column(modifier = Modifier.wrapContentWidth()) {
@@ -28,8 +30,11 @@ fun StockHeader(
if (resultMessage.isNotEmpty()) {
Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
shape = RoundedCornerShape(4.dp)
modifier = Modifier.padding(bottom = 8.dp),
shape = RoundedCornerShape(4.dp),
onClick = {
resultMessageClear.invoke()
}
) {
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
}
-53
View File
@@ -1,53 +0,0 @@
package ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.CandleData
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun SummaryGraphCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
Card(modifier = modifier.height(60.dp), elevation = 2.dp, backgroundColor = Color.White) {
Column(modifier = Modifier.padding(4.dp)) {
Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
if (data.isNotEmpty()) {
Canvas(modifier = Modifier.fillMaxSize()) {
val prices = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }
val max = prices.maxOrNull() ?: 1.0
val min = prices.minOrNull() ?: 0.0
val range = if (max == min) 1.0 else max - min
val stepX = size.width / (prices.size - 1).coerceAtLeast(1)
val points = prices.mapIndexed { i, p ->
Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat())
}
// 추세선 그리기
for (i in 0 until points.size - 1) {
drawLine(
color = if (prices.last() >= prices.first()) Color.Red else Color.Blue,
start = points[i],
end = points[i + 1],
strokeWidth = 2f
)
}
}
}
}
}
}