This commit is contained in:
2026-01-14 15:42:26 +09:00
parent 2bb94e2856
commit bdc268e325
18 changed files with 1193 additions and 205 deletions
+104
View File
@@ -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
)
}
}
}
}
}
+100
View File
@@ -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)
}
}
}
}
+9 -11
View File
@@ -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
+74 -5
View File
@@ -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)
)
}
}
}
+57
View File
@@ -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
)
}
}
}
}
}
}
}
+7 -1
View File
@@ -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 = {
+116 -29
View File
@@ -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)
}
}
}
+31 -42
View File
@@ -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)
}
}
+53
View File
@@ -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
)
}
}
}
}
}
}