....
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
// src/main/kotlin/ui/ActiveTradeRow.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
|
||||
@Composable
|
||||
fun ActiveTradeRow(
|
||||
item: ActiveTradeItem,
|
||||
onCancelClick: (String) -> Unit = {}, // 미체결 취소용
|
||||
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)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 2.dp)
|
||||
.clickable { onClick() },
|
||||
elevation = 2.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
backgroundColor = backgroundColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 상태 배지 (자동감시 / 미체결)
|
||||
Surface(
|
||||
color = badgeColor,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isMonitoring) "자동감시" else "미체결",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = item.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 우측 액션 영역
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
if (!isMonitoring) {
|
||||
// 미체결인 경우 취소 버튼 표시
|
||||
Button(
|
||||
onClick = { onCancelClick(item.id) },
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일)
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.clickable
|
||||
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
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
import network.KisTradeService
|
||||
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt
|
||||
|
||||
@Composable
|
||||
fun AutoTradeSection(
|
||||
tradeService: KisTradeService,
|
||||
refreshTrigger: Int, // 갱신 트리거 추가
|
||||
onRefresh: () -> Unit,
|
||||
onItemSelect: (ActiveTradeItem) -> Unit
|
||||
) {
|
||||
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
|
||||
var combinedList by remember { mutableStateOf(emptyList<ActiveTradeItem>()) }
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
combinedList = monitoringItems + unfilledItems
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 강제 갱신 버튼
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn {
|
||||
items(combinedList) { item ->
|
||||
ActiveTradeRow(
|
||||
item = item,
|
||||
onCancelClick = { orderNo ->
|
||||
// tradeService.cancelOrder(orderNo, item.code) 호출 로직
|
||||
},
|
||||
onClick = {
|
||||
onItemSelect(item) // 상세 화면 전환용 콜백
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,22 +17,20 @@ fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val candleCount = data.size
|
||||
val candleWidth = width / candleCount
|
||||
val spacing = candleWidth * 0.2f // 캔들 사이 간격
|
||||
|
||||
// 1. 가격 범위 계산 (스케일링용)
|
||||
// 데이터가 적을 때도 일정한 너비를 유지하도록 개선
|
||||
val maxDisplayCount = 50
|
||||
val candleWidth = width / maxOf(data.size, maxDisplayCount)
|
||||
val spacing = candleWidth * 0.2f
|
||||
|
||||
// 가격 범위 계산 (여백 추가)
|
||||
val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
|
||||
val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
|
||||
val priceRange = maxPrice - minPrice
|
||||
val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 }
|
||||
val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백
|
||||
|
||||
// priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지
|
||||
fun getY(price: Double): Float {
|
||||
if (priceRange == 0.0) return height / 2f
|
||||
return (height - ((price - minPrice) / priceRange * height)).toFloat()
|
||||
}
|
||||
fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat()
|
||||
|
||||
// 루프 내부에서도 동일하게 적용
|
||||
data.forEachIndexed { index, candle ->
|
||||
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/main/kotlin/ui/DashboardScreen.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.KisSession
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
@@ -17,20 +19,76 @@ import network.KisWebSocketManager
|
||||
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) }
|
||||
|
||||
// 초기 웹소켓 연결
|
||||
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")
|
||||
}
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
// 3. 목표가 도달 콜백 설정 (자동 매도 실행)
|
||||
wsManager.onTargetReached = { code, price, isProfit ->
|
||||
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 손절 조건 달성"
|
||||
)
|
||||
}
|
||||
}
|
||||
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.25f).fillMaxHeight().padding(8.dp)) {
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
BalanceSection(tradeService) { code, name, isDom ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
@@ -59,9 +117,20 @@ fun DashboardScreen() {
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
AutoTradeSection(
|
||||
tradeService = tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger // 트리거 전달
|
||||
) { item ->
|
||||
selectedStockCode = item.code
|
||||
selectedStockName = item.name
|
||||
isDomestic = item.isDomestic
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
MarketSection(tradeService) { code, name, isDom ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// src/main/kotlin/ui/IntegratedOrderSection.kt
|
||||
package ui
|
||||
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun IntegratedOrderSection(
|
||||
stockCode: String,
|
||||
currentPrice: String,
|
||||
tradeService: KisTradeService,
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
||||
|
||||
// 자동 매도 설정
|
||||
var isAutoSellEnabled by remember { mutableStateOf(false) }
|
||||
var profitRate by remember { mutableStateOf("5.0") }
|
||||
var stopLossRate by remember { mutableStateOf("-3.0") }
|
||||
|
||||
val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0
|
||||
val qty = orderQty.toDoubleOrNull() ?: 0.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,
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 3. 자동 매도 옵션
|
||||
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)
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) { /* 자동매도 등록 로직 호출 */ }
|
||||
}
|
||||
.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 = { /* 매도 로직동일 */ },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
|
||||
) { Text("매도", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimulationColumn(title: String, items: List<String>, isHeader: Boolean) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합)
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.geometry.Offset
|
||||
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.CandleData
|
||||
|
||||
@Composable
|
||||
fun PeriodTrendCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
val avgPrice = if (data.isEmpty()) "0"
|
||||
else String.format("%,d", data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average().toLong())
|
||||
|
||||
Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// [좌측] 라벨 및 평균가
|
||||
Column(modifier = Modifier.weight(0.4f)) {
|
||||
Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
// [우측] 간소화된 그래프 (Sparkline)
|
||||
Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) {
|
||||
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(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
start = points[i],
|
||||
end = points[i + 1],
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,13 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
|
||||
Text("모의투자")
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = config.htsId,
|
||||
onValueChange = { config = config.copy(htsId = it) },
|
||||
label = { Text("HTS ID (실시간 체결 통보용)") },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") }
|
||||
)
|
||||
// 실전 3종 입력
|
||||
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(value = config.realAccountNo, onValueChange = {
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
@@ -41,10 +42,29 @@ fun StockDetailSection(
|
||||
tradeService: KisTradeService,
|
||||
wsManager: KisWebSocketManager
|
||||
) {
|
||||
|
||||
var openPrice by remember { mutableStateOf("0") }
|
||||
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var resultMessage by remember { mutableStateOf("") }
|
||||
var isSuccess by remember { mutableStateOf(true) }
|
||||
var daySummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var weekSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var monthSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var yearSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
|
||||
val todayOpen = remember(daySummary) {
|
||||
daySummary.lastOrNull()?.stck_oprc ?: "0"
|
||||
}
|
||||
val previousClose = remember(daySummary) {
|
||||
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_clpr else "0"
|
||||
}
|
||||
|
||||
fun calculateAvg(data: List<CandleData>): String {
|
||||
if (data.isEmpty()) return "0"
|
||||
val avg = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average()
|
||||
return String.format("%,d", avg.toLong())
|
||||
}
|
||||
|
||||
// 이전 종목 코드를 기억하기 위한 상태
|
||||
var previousCode by remember { mutableStateOf("") }
|
||||
@@ -59,49 +79,103 @@ fun StockDetailSection(
|
||||
if (previousCode.isNotEmpty()) {
|
||||
wsManager.unsubscribeStock(previousCode)
|
||||
}
|
||||
wsManager.clearData()
|
||||
wsManager.subscribeStock(stockCode)
|
||||
previousCode = stockCode
|
||||
|
||||
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
||||
tradeService.fetchChartData(stockCode, isDomestic)
|
||||
.onSuccess { data ->
|
||||
println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
chartData = data
|
||||
}
|
||||
.onFailure { error ->
|
||||
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
launch {tradeService.fetchChartData(stockCode, isDomestic)
|
||||
.onSuccess { data ->
|
||||
println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
chartData = data
|
||||
}
|
||||
.onFailure { error ->
|
||||
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}}
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { daySummary = it.takeLast(7) } } // 최근 7일
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } } // 최근 4주
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
|
||||
monthSummary = it.takeLast(6) // 최근 6개월
|
||||
yearSummary = it.takeLast(36) // 최근 3년
|
||||
} }
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
|
||||
|
||||
LaunchedEffect(latestPrice) {
|
||||
println("latestPrice $latestPrice")
|
||||
|
||||
if (chartData.isNotEmpty() && latestPrice != "0") {
|
||||
|
||||
// 마지막 캔들 정보 업데이트
|
||||
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
|
||||
val lastCandle = chartData.last()
|
||||
|
||||
val updatedCandle = lastCandle.copy(
|
||||
stck_clpr = latestPrice,
|
||||
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
)
|
||||
// 현재 시간(분 단위) 확인
|
||||
val currentMinute = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HHmm00"))
|
||||
|
||||
chartData = chartData.dropLast(1) + updatedCandle
|
||||
println("chartData.size $chartData.size")
|
||||
if (lastCandle.stck_bsop_date != currentMinute) {
|
||||
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||
val newCandle = CandleData(
|
||||
stck_bsop_date = currentMinute,
|
||||
stck_oprc = latestPrice,
|
||||
stck_hgpr = latestPrice,
|
||||
stck_lwpr = latestPrice,
|
||||
stck_clpr = latestPrice,
|
||||
acml_vol = "0"
|
||||
)
|
||||
// 최대 100개까지만 유지하여 성능 최적화
|
||||
chartData = (chartData + newCandle).takeLast(100)
|
||||
} else {
|
||||
// 같은 분 내에서는 기존 마지막 캔들만 업데이트
|
||||
val updatedCandle = lastCandle.copy(
|
||||
stck_clpr = latestPrice,
|
||||
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
)
|
||||
chartData = chartData.dropLast(1) + updatedCandle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// [상단] 종목명 및 상태 메시지
|
||||
StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StockHeader(
|
||||
name = stockName,
|
||||
code = stockCode,
|
||||
isDomestic = isDomestic,
|
||||
previousClose = previousClose,
|
||||
openPrice = openPrice,
|
||||
resultMessage = resultMessage,
|
||||
isSuccess = isSuccess
|
||||
)
|
||||
|
||||
// 실시간 가격 표시 (WebSocket 데이터)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "${wsManager.currentPrice.value} 원",
|
||||
style = MaterialTheme.typography.h4,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (wsManager.currentPrice.value.contains("-")) Color.Blue else Color.Red
|
||||
)
|
||||
Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
// 통합된 트렌드 카드 배치
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PeriodTrendCard("7일", daySummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("4주", weekSummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("3년", yearSummary, Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
// [중앙] 캔들 차트 (Card 내부)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||
@@ -136,14 +210,27 @@ fun StockDetailSection(
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 주문 섹션 (인자 간소화)
|
||||
OrderSection(
|
||||
stockCode = stockCode,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
)
|
||||
Column(modifier = Modifier.weight(0.6f)) {
|
||||
IntegratedOrderSection(
|
||||
stockCode = stockCode,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
tradeService = tradeService,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -17,64 +18,52 @@ fun StockHeader(
|
||||
name: String,
|
||||
code: String,
|
||||
isDomestic: Boolean,
|
||||
previousClose: String, // 추가: 전일 종가
|
||||
openPrice: String, // 추가: 금일 시가
|
||||
resultMessage: String,
|
||||
isSuccess: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시)
|
||||
Column(modifier = Modifier.wrapContentWidth()) {
|
||||
// [1] 알림 메시지 영역 (기존 동일)
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = resultMessage,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
// [2] 종목명 및 국가 배지 영역
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
// 국가 구분 배지
|
||||
// [2] 종목명 및 정보
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isDomestic) "국내" else "해외",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 종목 이름
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = "($code)", color = Color.Gray)
|
||||
}
|
||||
|
||||
// 종목 코드
|
||||
Text(
|
||||
text = "($code)",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = Color.Gray
|
||||
)
|
||||
// [3] 전일 종가 및 시가 정보 행 추가
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
PriceSummaryItem("전일 종가", previousClose)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
PriceSummaryItem("금일 시가", openPrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PriceSummaryItem(label: String, price: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = label, fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price
|
||||
Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user