빨라져라

This commit is contained in:
2026-04-07 17:32:21 +09:00
parent a700d54dfe
commit 9172cca791
24 changed files with 3337 additions and 3253 deletions
+117 -117
View File
@@ -1,117 +1,117 @@
// 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.*
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
@Composable
fun ActiveTradeRow(
item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
onClick: () -> Unit
) {
// 상태에 따른 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
.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
) {
// 좌측 정보 영역
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// 상태 배지 표시
Surface(
color = statusColor,
shape = RoundedCornerShape(2.dp),
modifier = Modifier.padding(end = 6.dp)
) {
Text(
text = statusText,
color = Color.White,
fontSize = 9.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontWeight = FontWeight.Bold
)
}
Text(
text = item.name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
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} ${item.orderedPrice} ${item.quantity}"
}
Text(
text = "${item.code} | $detailText",
fontSize = 11.sp,
color = Color.Gray
)
}
// 우측 액션 및 수량 영역
Column(horizontalAlignment = Alignment.End) {
if (item.status == "PENDING_BUY" || item.status == "SELLING") {
// 진행 중인 주문인 경우 취소 버튼 노출
Button(
onClick = { onCancelClick() },
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.height(28.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
) {
Text("취소", fontSize = 11.sp)
}
} else {
Button(
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 = statusColor
)
}
}
}
}
//// 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.*
//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
//
//@Composable
//fun ActiveTradeRow(
// item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
// onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
// onClick: () -> Unit
//) {
// // 상태에 따른 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
// .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
// ) {
// // 좌측 정보 영역
// Column(modifier = Modifier.weight(1f)) {
// Row(verticalAlignment = Alignment.CenterVertically) {
// // 상태 배지 표시
// Surface(
// color = statusColor,
// shape = RoundedCornerShape(2.dp),
// modifier = Modifier.padding(end = 6.dp)
// ) {
// Text(
// text = statusText,
// color = Color.White,
// fontSize = 9.sp,
// modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
// fontWeight = FontWeight.Bold
// )
// }
// Text(
// text = item.name,
// style = MaterialTheme.typography.body2,
// fontWeight = FontWeight.Bold,
// 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} ${item.orderedPrice} ${item.quantity}"
// }
//
// Text(
// text = "${item.code} | $detailText",
// fontSize = 11.sp,
// color = Color.Gray
// )
// }
//
// // 우측 액션 및 수량 영역
// Column(horizontalAlignment = Alignment.End) {
// if (item.status == "PENDING_BUY" || item.status == "SELLING") {
// // 진행 중인 주문인 경우 취소 버튼 노출
// Button(
// onClick = { onCancelClick() },
// contentPadding = PaddingValues(horizontal = 8.dp),
// modifier = Modifier.height(28.dp),
// colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
// ) {
// Text("취소", fontSize = 11.sp)
// }
// } else {
// Button(
// 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 = statusColor
// )
// }
// }
// }
//}
+98 -99
View File
@@ -1,100 +1,99 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.*
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 kotlinx.coroutines.launch
import model.KisSession
import service.AutoTradingManager
import service.TechnicalAnalyzer
import service.TradingDecisionCallback
@Composable
fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var code by remember(stockCode) {
aiOpinion = ""
mutableStateOf(stockCode.isNotEmpty())
}
var isAnalyzing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// KisSession의 전역 설정을 참조
val isModelConfigured = remember(KisSession.config.modelPath) {
val path = KisSession.config.modelPath
path.isNotEmpty() && java.io.File(path).exists()
}
Card(
elevation = 2.dp,
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.15F)
.verticalScroll(rememberScrollState()) // 스크롤 활성화
.padding(16.dp)
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요",
fontWeight = FontWeight.Bold,
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
)
Spacer(Modifier.weight(1f))
Button(
onClick = {
scope.launch {
isAnalyzing = true
try {
AutoTradingManager.addStock(currentPrice.replace(",","").toDouble(),technicalAnalyzer,stockName,stockCode) { decision,success ->
aiOpinion = decision.toString()
isAnalyzing = !success
tradingDecisionCallback.invoke(decision,success)
}
} catch (e: Exception) {
aiOpinion = "분석 중 오류 발생: ${e.message}"
println(aiOpinion)
isAnalyzing = false
} finally {
//package ui
//
//import androidx.compose.foundation.background
//import androidx.compose.foundation.layout.Column
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.rememberScrollState
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.foundation.verticalScroll
//import androidx.compose.material.Button
//import androidx.compose.material.Card
//import androidx.compose.material.CircularProgressIndicator
//import androidx.compose.material.Divider
//import androidx.compose.material.MaterialTheme
//import androidx.compose.material.Text
//import androidx.compose.runtime.Composable
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.mutableStateOf
//import androidx.compose.runtime.remember
//import androidx.compose.runtime.rememberCoroutineScope
//import androidx.compose.runtime.setValue
//import androidx.compose.ui.*
//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 kotlinx.coroutines.launch
//import model.KisSession
//import service.AutoTradingManager
//import service.TradingDecisionCallback
//
//@Composable
//fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
// var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
// var code by remember(stockCode) {
// aiOpinion = ""
// mutableStateOf(stockCode.isNotEmpty())
// }
// var isAnalyzing by remember { mutableStateOf(false) }
// val scope = rememberCoroutineScope()
//
// // KisSession의 전역 설정을 참조
// val isModelConfigured = remember(KisSession.config.modelPath) {
// val path = KisSession.config.modelPath
// path.isNotEmpty() && java.io.File(path).exists()
// }
//
// Card(
// elevation = 2.dp,
// backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
// modifier = Modifier.fillMaxWidth()
// ) {
// Column(modifier = Modifier
// .fillMaxWidth()
// .fillMaxHeight(0.15F)
// .verticalScroll(rememberScrollState()) // 스크롤 활성화
// .padding(16.dp)
// .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) {
// Row(verticalAlignment = Alignment.CenterVertically) {
// Text(
// text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요",
// fontWeight = FontWeight.Bold,
// color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
// )
// Spacer(Modifier.weight(1f))
// Button(
// onClick = {
// scope.launch {
// isAnalyzing = true
// try {
// AutoTradingManager.addStock(currentPrice.replace(",","").toDouble(),technicalAnalyzer,stockName,stockCode) { decision,success ->
// aiOpinion = decision.toString()
// isAnalyzing = !success
// tradingDecisionCallback.invoke(decision,success)
// }
// } catch (e: Exception) {
// aiOpinion = "분석 중 오류 발생: ${e.message}"
// println(aiOpinion)
// isAnalyzing = false
}
}
},
enabled = !isAnalyzing && code
) {
if (isAnalyzing) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
Spacer(Modifier.width(8.dp))
Text("뉴스 분석 중...")
} else {
Text("분석 요청")
}
}
}
Divider(Modifier.padding(vertical = 8.dp))
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
}
}
}
// } finally {
//// isAnalyzing = false
// }
// }
// },
// enabled = !isAnalyzing && code
// ) {
// if (isAnalyzing) {
// CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
// Spacer(Modifier.width(8.dp))
// Text("뉴스 분석 중...")
// } else {
// Text("분석 요청")
// }
// }
// }
// Divider(Modifier.padding(vertical = 8.dp))
// Text(text = aiOpinion, style = MaterialTheme.typography.body2)
// }
// }
//}
+95 -95
View File
@@ -1,95 +1,95 @@
// 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.toAutoTradeItem
import network.KisTradeService
// src/main/kotlin/ui/AutoTradeSection.kt
@Composable
fun AutoTradeSection(
isDomestic: Boolean,
tradeService: KisTradeService,
refreshTrigger: Int, // 갱신 트리거 추가
onRefresh: () -> Unit,
onItemSelect: (AutoTradeItem) -> Unit,
onItemCancel: (AutoTradeItem) -> Unit
) {
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
// refreshTrigger가 바뀔 때마다 실행됨
LaunchedEffect(refreshTrigger) {
// 1. 서버에서 실제 미체결 내역 가져오기
val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
// 2. DB에서 로컬 감시 데이터 가져오기
val localTrades = DatabaseFactory.getActiveAutoTrades()
// 3. 리스트 병합 및 동기화
val mergedList = mutableListOf<AutoTradeItem>()
// (A) DB에 있는 항목 처리
localTrades.forEach { local ->
val serverMatch = serverUnfilled.find { it.orderNo == local.orderNo }
if (local.status != "COMPLETED" && serverMatch == null) {
// 서버에 없으면 만료 처리
mergedList.add(local.copy(status = "EXPIRED"))
} else {
// 서버에 있으면 그대로 표시 (필요시 잔량 등 업데이트)
mergedList.add(local.copy(remainedQuantity = serverMatch?.remainedQuantity ?: 0))
}
}
// (B) 서버에는 있지만 DB에는 없는 항목(수동 주문 등) 추가
val manualOrders = serverUnfilled.filter { server -> localTrades.none { it.orderNo == server.orderNo } }
mergedList.addAll(manualOrders.map { it.copy(status = "MANUAL_ORDER") }) // 수동 주문 상태 등으로 표시
tradeList = mergedList
}
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(tradeList) { item ->
ActiveTradeRow(
item = item,
onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
onClick = { onItemSelect(item) }
)
}
}
}
}
//// 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.toAutoTradeItem
//import network.KisTradeService
//
//// src/main/kotlin/ui/AutoTradeSection.kt
//
//@Composable
//fun AutoTradeSection(
// isDomestic: Boolean,
// tradeService: KisTradeService,
// refreshTrigger: Int, // 갱신 트리거 추가
// onRefresh: () -> Unit,
// onItemSelect: (AutoTradeItem) -> Unit,
// onItemCancel: (AutoTradeItem) -> Unit
//) {
// // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
// var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
// // refreshTrigger가 바뀔 때마다 실행됨
// LaunchedEffect(refreshTrigger) {
// // 1. 서버에서 실제 미체결 내역 가져오기
// val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
//
// // 2. DB에서 로컬 감시 데이터 가져오기
// val localTrades = DatabaseFactory.getActiveAutoTrades()
//
// // 3. 리스트 병합 및 동기화
// val mergedList = mutableListOf<AutoTradeItem>()
//
// // (A) DB에 있는 항목 처리
// localTrades.forEach { local ->
// val serverMatch = serverUnfilled.find { it.orderNo == local.orderNo }
// if (local.status != "COMPLETED" && serverMatch == null) {
// // 서버에 없으면 만료 처리
// mergedList.add(local.copy(status = "EXPIRED"))
// } else {
// // 서버에 있으면 그대로 표시 (필요시 잔량 등 업데이트)
// mergedList.add(local.copy(remainedQuantity = serverMatch?.remainedQuantity ?: 0))
// }
// }
//
// // (B) 서버에는 있지만 DB에는 없는 항목(수동 주문 등) 추가
// val manualOrders = serverUnfilled.filter { server -> localTrades.none { it.orderNo == server.orderNo } }
// mergedList.addAll(manualOrders.map { it.copy(status = "MANUAL_ORDER") }) // 수동 주문 상태 등으로 표시
//
// tradeList = mergedList
// }
//
// 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(tradeList) { item ->
// ActiveTradeRow(
// item = item,
// onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
// onClick = { onItemSelect(item) }
// )
// }
// }
// }
//}
+180 -180
View File
@@ -1,180 +1,180 @@
// src/main/kotlin/ui/BalanceSection.kt
package ui
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.UnifiedBalance
import network.KisTradeService
@Composable
fun BalanceSection(
tradeService: KisTradeService,
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(refreshTrigger) {
isLoading = true
tradeService.fetchIntegratedBalance().onSuccess {
balanceData = it
}.onFailure {
println("❌ 잔고 로드 실패: ${it.localizedMessage}")
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
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)
Spacer(modifier = Modifier.height(16.dp))
// 2. 통합 보유 종목 리스트
Text(
text = "보유 종목",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
if (isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.weight(1f, true)) {
items(balanceData?.holdings ?: emptyList()) { holding ->
UnifiedStockItemRow(holding) {
onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
}
}
}
}
}
}
@Composable
fun BalanceSummaryCard(summary: UnifiedBalance?) {
Card(
elevation = 2.dp,
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
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(
text = "${summary?.totalAsset ?: "0"}",
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold
)
val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0
val 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
Text(
text = "수익률: ${if (rate > 0) "+" else ""}$rate%",
color = color,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Medium
)
}
}
}
@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
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
// 국내/해외 구분 배지
Surface(
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD)
else androidx.compose.ui.graphics.Color(0xFFF3E5F5),
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
) {
Text(
text = if (holding.isDomestic) "국내" else "해외",
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontSize = 10.sp,
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue
else androidx.compose.ui.graphics.Color(0xFF7B1FA2)
)
}
Spacer(Modifier.width(4.dp))
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
}
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
}
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 ""}${holding.profitRate}%",
color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Text(
"매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
fontSize = 11.sp, color = Color.Gray
)
}
}
}
}
//// src/main/kotlin/ui/BalanceSection.kt
//package ui
//
//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.UnifiedBalance
//import network.KisTradeService
//
//@Composable
//fun BalanceSection(
// tradeService: KisTradeService,
// 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(refreshTrigger) {
// isLoading = true
// tradeService.fetchIntegratedBalance().onSuccess {
// balanceData = it
// }.onFailure {
// println("❌ 잔고 로드 실패: ${it.localizedMessage}")
// }
// isLoading = false
// }
//
// Column(modifier = Modifier.fillMaxSize()) {
// 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)
//
// Spacer(modifier = Modifier.height(16.dp))
//
// // 2. 통합 보유 종목 리스트
// Text(
// text = "보유 종목",
// style = MaterialTheme.typography.subtitle1,
// fontWeight = FontWeight.Bold,
// modifier = Modifier.padding(vertical = 8.dp)
// )
//
// if (isLoading) {
// Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
// CircularProgressIndicator()
// }
// } else {
// LazyColumn(modifier = Modifier.weight(1f, true)) {
// items(balanceData?.holdings ?: emptyList()) { holding ->
// UnifiedStockItemRow(holding) {
// onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
// }
// }
// }
// }
// }
//}
//
//@Composable
//fun BalanceSummaryCard(summary: UnifiedBalance?) {
// Card(
// elevation = 2.dp,
// shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
// 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(
// text = "${summary?.totalAsset ?: "0"} 원",
// style = MaterialTheme.typography.h5,
// fontWeight = FontWeight.Bold
// )
//
// val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0
// val 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
//
// Text(
// text = "수익률: ${if (rate > 0) "+" else ""}$rate%",
// color = color,
// style = MaterialTheme.typography.body2,
// fontWeight = FontWeight.Medium
// )
// }
// }
//}
//
//@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
// ) {
// Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
// Column(modifier = Modifier.weight(1f)) {
// Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
// // 국내/해외 구분 배지
// Surface(
// color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD)
// else androidx.compose.ui.graphics.Color(0xFFF3E5F5),
// shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
// ) {
// Text(
// text = if (holding.isDomestic) "국내" else "해외",
// modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
// fontSize = 10.sp,
// color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue
// else androidx.compose.ui.graphics.Color(0xFF7B1FA2)
// )
// }
// Spacer(Modifier.width(4.dp))
// Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
// }
// Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
// }
//
// 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 ""}${holding.profitRate}%",
// color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
// fontSize = 12.sp,
// fontWeight = FontWeight.Bold
// )
// Text(
// "매수: ${String.format("%,.0f", avgPrice)}${holding.quantity}",
// fontSize = 11.sp, color = Color.Gray
// )
// }
// }
// }
//}
+66 -66
View File
@@ -1,66 +1,66 @@
package ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import model.CandleData
@Composable
fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
if (data.isEmpty()) return
Canvas(modifier = modifier.fillMaxSize()) {
val width = size.width
val height = size.height
// 데이터가 적을 때도 일정한 너비를 유지하도록 개선
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).let { if (it == 0.0) 1.0 else it * 1.1 }
val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백
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_prpr.toDoubleOrNull() ?: 0.0
val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
val isRising = close >= open
val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)
val x = index * candleWidth + spacing / 2
val currentCandleWidth = candleWidth - spacing
// 2. 꼬리 그리기 (High-Low Line)
drawLine(
color = color,
start = Offset(x + currentCandleWidth / 2, getY(high)),
end = Offset(x + currentCandleWidth / 2, getY(low)),
strokeWidth = 2f
)
// 3. 몸통 그리기 (Open-Close Rect)
val bodyTop = getY(maxOf(open, close))
val bodyBottom = getY(minOf(open, close))
val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장
drawRect(
color = color,
topLeft = Offset(x, bodyTop),
size = Size(currentCandleWidth, bodyHeight)
)
}
}
}
//package ui
//
//import androidx.compose.foundation.Canvas
//import androidx.compose.foundation.layout.fillMaxSize
//import androidx.compose.runtime.Composable
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.geometry.Offset
//import androidx.compose.ui.geometry.Size
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.graphics.drawscope.Stroke
//import model.CandleData
//
//@Composable
//fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
// if (data.isEmpty()) return
//
// Canvas(modifier = modifier.fillMaxSize()) {
// val width = size.width
// val height = size.height
//
// // 데이터가 적을 때도 일정한 너비를 유지하도록 개선
// 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).let { if (it == 0.0) 1.0 else it * 1.1 }
// val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백
//
// 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_prpr.toDoubleOrNull() ?: 0.0
// val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
// val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
//
// val isRising = close >= open
// val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)
//
// val x = index * candleWidth + spacing / 2
// val currentCandleWidth = candleWidth - spacing
//
// // 2. 꼬리 그리기 (High-Low Line)
// drawLine(
// color = color,
// start = Offset(x + currentCandleWidth / 2, getY(high)),
// end = Offset(x + currentCandleWidth / 2, getY(low)),
// strokeWidth = 2f
// )
//
// // 3. 몸통 그리기 (Open-Close Rect)
// val bodyTop = getY(maxOf(open, close))
// val bodyBottom = getY(minOf(open, close))
// val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장
//
// drawRect(
// color = color,
// topLeft = Offset(x, bodyTop),
// size = Size(currentCandleWidth, bodyHeight)
// )
// }
// }
//}
+494 -494
View File
@@ -1,495 +1,495 @@
// src/main/kotlin/ui/DashboardScreen.kt
package ui
import AutoTradeItem
import network.TradingDecision
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import model.CandleData
import model.ConfigIndex
import model.ExecutionData
import model.KisSession
import model.StockBasicInfo
import network.KisTradeService
import network.KisWebSocketManager
import service.AutoTradingManager
import service.TechnicalAnalyzer
import service.TradingDecisionCallback
import util.MarketUtil
import kotlin.collections.mutableListOf
@Composable
fun DashboardScreen() {
val tradeService = remember { KisTradeService }
val wsManager = remember { KisWebSocketManager }
val scope = rememberCoroutineScope()
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) } // 단순 종목 선택 시
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
var min30 by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var daySummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var weekSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var callback = object : TradingDecisionCallback {
override fun invoke(decision: TradingDecision?, isSuccess: Boolean) {
if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
decision?.stockCode?.let { stockCode ->
decision?.stockName?.let { stockName ->
selectedStockCode = stockCode
selectedStockName = stockName
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
}
}
}else if (isSuccess && decision != null) {
if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) {
selectedStockCode = decision.stockCode
selectedStockName = decision.stockName
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
}
// 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
completeTradingDecision = decision
}
}
}
// 리소스 정리는 여전히 DisposableEffect에서 수행
DisposableEffect(Unit) {
onDispose {
AutoTradingManager.stopDiscovery()
}
}
// 중앙 관리용 상태들
var refreshTrigger by remember { mutableStateOf(0) }
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
LaunchedEffect(refreshTrigger) {
// setupAutoTradingWatchdog(tradeService,callback)
}
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
suspend fun syncAndExecute(orderNo: String) {
if (processingIds.contains(orderNo)) return
processingIds.add(orderNo)
try {
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
val execData = executionCache[orderNo]
if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) {
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
tradeService.postOrder(
stockCode = dbItem.code,
qty = dbItem.quantity.toString(),
price = finalTargetPrice.toLong().toString(),
isBuy = false
).onSuccess { newSellOrderNo ->
// 익절가 업데이트 및 상태 변경
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
// (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
executionCache.remove(orderNo)
refreshTrigger++
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
}
} else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
refreshTrigger++
}
}
} finally {
processingIds.remove(orderNo)
}
}
LaunchedEffect(Unit) {
// 1. 웹소켓 연결
wsManager.connect()
// 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.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
scope.launch {
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
executionCache[orderNo] = exec
syncAndExecute(orderNo)
}
}
}
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
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")
}
}
VerticalDivider()
// [중앙 45%] 실시간 정보 및 주문
Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
if (selectedStockCode.isNotEmpty()) {
StockDetailSection(
min30 = min30,
daySummary = daySummary,
monthSummary = monthSummary,
weekSummary = weekSummary,
yearSummary = yearSummary,
stockCode = selectedStockCode,
stockName = selectedStockName,
holdingQuantity = selectedStockQuantity,
isDomestic = isDomestic,
tradeService = tradeService,
wsManager = wsManager,
onOrderSaved = { orderNo ->
scope.launch {
syncAndExecute(orderNo) // 매칭 시도
}
},
completeTradingDecision = completeTradingDecision,
)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("분석할 종목을 선택하세요", color = Color.Gray)
}
}
}
VerticalDivider()
Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) {
AiAnalysisView(
technicalAnalyzer = TechnicalAnalyzer().apply {
this.min30 = min30
this.daily = daySummary
this.weekly = weekSummary
this.monthly = monthSummary
this.weekly = weekSummary
},
stockCode = selectedStockCode,
stockName = selectedStockName,
currentPrice = "0",
trades = wsManager.tradeLogs,
tradingDecisionCallback = { decision,bool ->
if (bool && decision != null && KisSession.config.isSimulation) {
completeTradingDecision = decision
}
}
)
Spacer(modifier = Modifier.height(16.dp))
Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2), // 2열 병렬 배치
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().weight(0.3f)
) {
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰 거래 기본 설정",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
var defaults = arrayOf(
ConfigIndex.TAX_INDEX,
ConfigIndex.PROFIT_INDEX,
ConfigIndex.BUY_WEIGHT_INDEX,
ConfigIndex.MAX_BUDGET_INDEX,
ConfigIndex.MAX_PRICE_INDEX,
ConfigIndex.MIN_PRICE_INDEX,
ConfigIndex.MIN_PURCHASE_SCORE_INDEX,
ConfigIndex.MAX_COUNT_INDEX,
)
items(defaults.size) { index ->
val configKey = defaults.get(index)
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
// 저장 로직을 공통 함수로 분리
val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0
if (configKey.label.contains("PROFIT")) {
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
}
KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue")
}
var text = if (configKey.label.contains("PROFIT")) {
"${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}"
} else {
localText
}
OutlinedTextField(
value = text,
onValueChange = { localText = it }, // 화면에는 즉시 반영
label = { Text(configKey.label) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
// 2. 포커스를 잃었을 때 저장
if (!focusState.isFocused) {
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true
)
}
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰매수 정책 및 기대 수익률",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
var defaults2 = arrayOf(
arrayOf(ConfigIndex.GRADE_5_BUY,
ConfigIndex.GRADE_5_PROFIT,),
arrayOf(ConfigIndex.GRADE_4_BUY,
ConfigIndex.GRADE_4_PROFIT,),
arrayOf(ConfigIndex.GRADE_3_BUY,
ConfigIndex.GRADE_3_PROFIT,),
arrayOf(ConfigIndex.GRADE_2_BUY,
ConfigIndex.GRADE_2_PROFIT,),
arrayOf(ConfigIndex.GRADE_1_BUY,
ConfigIndex.GRADE_1_PROFIT,),
)
for (items in defaults2) {
val common = findLongestCommonSubstring(items.first().label,items.last().label)
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
common,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
items(items.size) { index ->
val configKey = items.get(index)
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
var labelText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0
//// src/main/kotlin/ui/DashboardScreen.kt
//package ui
//
KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue")
labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}"
} else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
}
}
labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)} "
} else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
}
OutlinedTextField(
value = localText,
onValueChange = { localText = it }, // 화면에는 즉시 반영
label = { Text(labelText) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
// 2. 포커스를 잃었을 때 저장
if (!focusState.isFocused) {
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true
)
}
}
}
}
VerticalDivider()
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
AutoTradeSection(
isDomestic = isDomestic,
tradeService = tradeService,
onRefresh = { refreshTrigger++ },
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.12f).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
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
}
}
}
}
@Composable
fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}
fun findLongestCommonSubstring(s1: String, s2: String): String {
if (s1.isEmpty() || s2.isEmpty()) return ""
var longest = ""
// 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
val reference = if (s1.length <= s2.length) s1 else s2
val target = if (s1.length <= s2.length) s2 else s1
for (i in reference.indices) {
for (j in (i + longest.length + 1)..reference.length) {
val sub = reference.substring(i, j)
if (target.contains(sub)) {
if (sub.length > longest.length) {
longest = sub
}
} else {
// target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
break
}
}
}
return longest
}
fun getRemaining(original: String, common: String): String {
if (common.isEmpty()) return original
// 가장 처음 발견되는 공통 문자열을 한 번만 제거
return original.replaceFirst(common, "").trim()
}
//import AutoTradeItem
//import network.TradingDecision
//import androidx.compose.foundation.background
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.grid.GridCells
//import androidx.compose.foundation.lazy.grid.GridItemSpan
//import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
//import androidx.compose.foundation.text.KeyboardActions
//import androidx.compose.foundation.text.KeyboardOptions
//import androidx.compose.material.*
//import androidx.compose.runtime.*
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.focus.onFocusChanged
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.text.input.ImeAction
//import androidx.compose.ui.text.input.KeyboardType
//import androidx.compose.ui.unit.dp
//import kotlinx.coroutines.launch
//import model.CandleData
//import model.ConfigIndex
//import model.ExecutionData
//import model.KisSession
//import model.StockBasicInfo
//import network.KisTradeService
//import network.KisWebSocketManager
//import service.AutoTradingManager
//import service.TechnicalAnalyzer
//import service.TradingDecisionCallback
//import util.MarketUtil
//import kotlin.collections.mutableListOf
//
//@Composable
//fun DashboardScreen() {
// val tradeService = remember { KisTradeService }
// val wsManager = remember { KisWebSocketManager }
// val scope = rememberCoroutineScope()
// 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) } // 단순 종목 선택 시
// var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
//
//
// var min30 by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
// var daySummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
// var weekSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
// var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
// var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
//
//
//
// var callback = object : TradingDecisionCallback {
// override fun invoke(decision: TradingDecision?, isSuccess: Boolean) {
// if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
// decision?.stockCode?.let { stockCode ->
// decision?.stockName?.let { stockName ->
// selectedStockCode = stockCode
// selectedStockName = stockName
// isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
// }
// }
//
// }else if (isSuccess && decision != null) {
// if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) {
// selectedStockCode = decision.stockCode
// selectedStockName = decision.stockName
// isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
// }
// // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
// completeTradingDecision = decision
// }
// }
// }
//
//
//// 리소스 정리는 여전히 DisposableEffect에서 수행
// DisposableEffect(Unit) {
// onDispose {
// AutoTradingManager.stopDiscovery()
// }
// }
//
//// 중앙 관리용 상태들
// var refreshTrigger by remember { mutableStateOf(0) }
// // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
// val executionCache = remember { mutableMapOf<String, ExecutionData>() }
//
// // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
//
// LaunchedEffect(refreshTrigger) {
//// setupAutoTradingWatchdog(tradeService,callback)
// }
// val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
// suspend fun syncAndExecute(orderNo: String) {
// if (processingIds.contains(orderNo)) return
// processingIds.add(orderNo)
//
// try {
// val dbItem = DatabaseFactory.findByOrderNo(orderNo)
// val execData = executionCache[orderNo]
//
// if (dbItem != null && execData != null && execData.isFilled) {
// if (dbItem.status == TradeStatus.PENDING_BUY) {
// // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
// val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
//
// // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
//
// val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
//
// // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
// val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
//
// // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
// val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
//
// println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
//
// tradeService.postOrder(
// stockCode = dbItem.code,
// qty = dbItem.quantity.toString(),
// price = finalTargetPrice.toLong().toString(),
// isBuy = false
// ).onSuccess { newSellOrderNo ->
// // 익절가 업데이트 및 상태 변경
// DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
// // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
//
// executionCache.remove(orderNo)
// refreshTrigger++
// }.onFailure {
// println("❌ 익절 주문 실패: ${it.message}")
// }
// } else if (dbItem.status == TradeStatus.SELLING) {
// println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
// DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
// executionCache.remove(orderNo)
// refreshTrigger++
// }
// }
// } finally {
// processingIds.remove(orderNo)
// }
// }
//
// LaunchedEffect(Unit) {
// // 1. 웹소켓 연결
// wsManager.connect()
//
// // 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.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
// scope.launch {
// val exec = ExecutionData(orderNo, code, price, qty, isBuy)
// executionCache[orderNo] = exec
// syncAndExecute(orderNo)
// }
// }
// }
//
// Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// // [좌측 25%] 내 자산 및 통합 잔고
// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
// 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")
// }
// }
//
// VerticalDivider()
//
// // [중앙 45%] 실시간 정보 및 주문
// Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
// if (selectedStockCode.isNotEmpty()) {
// StockDetailSection(
// min30 = min30,
// daySummary = daySummary,
// monthSummary = monthSummary,
// weekSummary = weekSummary,
// yearSummary = yearSummary,
// stockCode = selectedStockCode,
// stockName = selectedStockName,
// holdingQuantity = selectedStockQuantity,
// isDomestic = isDomestic,
// tradeService = tradeService,
// wsManager = wsManager,
// onOrderSaved = { orderNo ->
// scope.launch {
// syncAndExecute(orderNo) // 매칭 시도
// }
// },
// completeTradingDecision = completeTradingDecision,
// )
// } else {
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// Text("분석할 종목을 선택하세요", color = Color.Gray)
// }
// }
// }
//
// VerticalDivider()
//
// Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) {
// AiAnalysisView(
// technicalAnalyzer = TechnicalAnalyzer().apply {
// this.min30 = min30
// this.daily = daySummary
// this.weekly = weekSummary
// this.monthly = monthSummary
// this.weekly = weekSummary
// },
// stockCode = selectedStockCode,
// stockName = selectedStockName,
// currentPrice = "0",
// trades = wsManager.tradeLogs,
// tradingDecisionCallback = { decision,bool ->
// if (bool && decision != null && KisSession.config.isSimulation) {
// completeTradingDecision = decision
// }
// }
// )
// Spacer(modifier = Modifier.height(16.dp))
// Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp))
// LazyVerticalGrid(
// columns = GridCells.Fixed(2), // 2열 병렬 배치
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// verticalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier.fillMaxWidth().weight(0.3f)
// ) {
// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
// Text(
// "💰 거래 기본 설정",
// style = MaterialTheme.typography.h6,
// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
// )
// }
// var defaults = arrayOf(
// ConfigIndex.TAX_INDEX,
// ConfigIndex.PROFIT_INDEX,
// ConfigIndex.BUY_WEIGHT_INDEX,
// ConfigIndex.MAX_BUDGET_INDEX,
// ConfigIndex.MAX_PRICE_INDEX,
// ConfigIndex.MIN_PRICE_INDEX,
// ConfigIndex.MIN_PURCHASE_SCORE_INDEX,
// ConfigIndex.MAX_COUNT_INDEX,
// )
// items(defaults.size) { index ->
// val configKey = defaults.get(index)
//
// // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
// var localText by remember(configKey) {
// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
// }
//
// // 저장 로직을 공통 함수로 분리
// val saveAction = {
// var newValue = localText.toDoubleOrNull() ?: 0.0
// if (configKey.label.contains("PROFIT")) {
// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
// }
// KisSession.config.setValues(configKey, newValue)
// DatabaseFactory.saveConfig(KisSession.config)
// println("💾 저장됨: ${configKey.label} = $newValue")
// }
//
// var text = if (configKey.label.contains("PROFIT")) {
// "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}"
// } else {
// localText
// }
//
// OutlinedTextField(
// value = text,
// onValueChange = { localText = it }, // 화면에는 즉시 반영
// label = { Text(configKey.label) },
// modifier = Modifier
// .fillMaxWidth()
// .onFocusChanged { focusState ->
// // 2. 포커스를 잃었을 때 저장
// if (!focusState.isFocused) {
// saveAction()
// }
// },
// keyboardOptions = KeyboardOptions(
// imeAction = ImeAction.Done,
// keyboardType = KeyboardType.Decimal
// ),
// keyboardActions = KeyboardActions(
// // 3. 엔터(Done) 키를 눌렀을 때 저장
// onDone = {
// saveAction()
// }
// ),
// singleLine = true
// )
// }
// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
// Text(
// "💰매수 정책 및 기대 수익률",
// style = MaterialTheme.typography.h6,
// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
// )
// }
// var defaults2 = arrayOf(
// arrayOf(ConfigIndex.GRADE_5_BUY,
// ConfigIndex.GRADE_5_PROFIT,),
// arrayOf(ConfigIndex.GRADE_4_BUY,
// ConfigIndex.GRADE_4_PROFIT,),
// arrayOf(ConfigIndex.GRADE_3_BUY,
// ConfigIndex.GRADE_3_PROFIT,),
// arrayOf(ConfigIndex.GRADE_2_BUY,
// ConfigIndex.GRADE_2_PROFIT,),
// arrayOf(ConfigIndex.GRADE_1_BUY,
// ConfigIndex.GRADE_1_PROFIT,),
// )
// for (items in defaults2) {
// val common = findLongestCommonSubstring(items.first().label,items.last().label)
// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
// Text(
// common,
// style = MaterialTheme.typography.h6,
// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
// )
// }
//
// items(items.size) { index ->
// val configKey = items.get(index)
//
// // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
// var localText by remember(configKey) {
// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
// }
//
// var labelText by remember(configKey) {
// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
// }
//
// val saveAction = {
// var newValue = localText.toDoubleOrNull() ?: 0.0
////
// KisSession.config.setValues(configKey, newValue)
// DatabaseFactory.saveConfig(KisSession.config)
// println("💾 저장됨: ${configKey.label} = $newValue")
// labelText = if (configKey.name.contains("PROFIT")) {
// getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
// ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
// ConfigIndex.TAX_INDEX)}"
// } else {
// getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
// }
// }
//
// labelText = if (configKey.name.contains("PROFIT")) {
// getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
// ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
// ConfigIndex.TAX_INDEX)} "
// } else {
// getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
// }
//
// OutlinedTextField(
// value = localText,
// onValueChange = { localText = it }, // 화면에는 즉시 반영
// label = { Text(labelText) },
// modifier = Modifier
// .fillMaxWidth()
// .onFocusChanged { focusState ->
// // 2. 포커스를 잃었을 때 저장
// if (!focusState.isFocused) {
// saveAction()
// }
// },
// keyboardOptions = KeyboardOptions(
// imeAction = ImeAction.Done,
// keyboardType = KeyboardType.Decimal
// ),
// keyboardActions = KeyboardActions(
// // 3. 엔터(Done) 키를 눌렀을 때 저장
// onDone = {
// saveAction()
// }
// ),
// singleLine = true
// )
// }
// }
// }
// }
// VerticalDivider()
// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
// AutoTradeSection(
// isDomestic = isDomestic,
// tradeService = tradeService,
// onRefresh = { refreshTrigger++ },
// 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.12f).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
// println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
// }
// }
// }
//
//
//}
//
//
//
//@Composable
//fun VerticalDivider() {
// Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
//}
//
//fun findLongestCommonSubstring(s1: String, s2: String): String {
// if (s1.isEmpty() || s2.isEmpty()) return ""
//
// var longest = ""
// // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
// val reference = if (s1.length <= s2.length) s1 else s2
// val target = if (s1.length <= s2.length) s2 else s1
//
// for (i in reference.indices) {
// for (j in (i + longest.length + 1)..reference.length) {
// val sub = reference.substring(i, j)
// if (target.contains(sub)) {
// if (sub.length > longest.length) {
// longest = sub
// }
// } else {
// // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
// break
// }
// }
// }
// return longest
//}
//
//fun getRemaining(original: String, common: String): String {
// if (common.isEmpty()) return original
// // 가장 처음 발견되는 공통 문자열을 한 번만 제거
// return original.replaceFirst(common, "").trim()
//}
File diff suppressed because it is too large Load Diff
+101 -101
View File
@@ -1,101 +1,101 @@
// src/main/kotlin/ui/MarketSection.kt
package ui
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.TabRowDefaults.tabIndicatorOffset
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.RankingStock
import model.RankingType
import network.KisTradeService
@Composable
fun MarketSection(
tradeService: KisTradeService,
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
) {
var selectedTab by remember { mutableStateOf(RankingType.VOLUME) }
var isDomestic by remember { mutableStateOf(true) }
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
// 탭 또는 국가 변경 시 데이터 로드
LaunchedEffect(selectedTab, isDomestic) {
isLoading = true
tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess {
rankingList = it
}.onFailure {
rankingList = emptyList()
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
// [1] 상단 타이틀 및 국내/해외 토글
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
// 국내/해외 전환 스위치 방식
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium)
Switch(
checked = !isDomestic,
onCheckedChange = { isDomestic = !it },
colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF))
)
}
}
// [2] 랭킹 타입 탭 (상승, 하락, 거래량 등)
ScrollableTabRow(
selectedTabIndex = selectedTab.ordinal,
backgroundColor = Color.Transparent,
contentColor = Color.Black,
edgePadding = 0.dp,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]),
color = Color(0xFFE03E2D)
)
}
) {
RankingType.values().forEach { type ->
Tab(
selected = selectedTab == type,
onClick = { selectedTab = type },
text = { Text(type.title, fontSize = 12.sp) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// [3] 랭킹 리스트
if (isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(strokeWidth = 2.dp)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(rankingList.withIndex().toList()) { (index, stock) ->
MarketStockItemRow(index + 1, stock) {
onStockSelect(stock.code, stock.name, isDomestic)
}
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
}
}
}
}
}
//// src/main/kotlin/ui/MarketSection.kt
//package ui
//
//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.TabRowDefaults.tabIndicatorOffset
//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.RankingStock
//import model.RankingType
//import network.KisTradeService
//
//@Composable
//fun MarketSection(
// tradeService: KisTradeService,
// onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
//) {
// var selectedTab by remember { mutableStateOf(RankingType.VOLUME) }
// var isDomestic by remember { mutableStateOf(true) }
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
// var isLoading by remember { mutableStateOf(false) }
//
// // 탭 또는 국가 변경 시 데이터 로드
// LaunchedEffect(selectedTab, isDomestic) {
// isLoading = true
// tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess {
// rankingList = it
// }.onFailure {
// rankingList = emptyList()
// }
// isLoading = false
// }
//
// Column(modifier = Modifier.fillMaxSize()) {
// // [1] 상단 타이틀 및 국내/해외 토글
// Row(
// modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
//
// // 국내/해외 전환 스위치 방식
// Row(verticalAlignment = Alignment.CenterVertically) {
// Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium)
// Switch(
// checked = !isDomestic,
// onCheckedChange = { isDomestic = !it },
// colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF))
// )
// }
// }
//
// // [2] 랭킹 타입 탭 (상승, 하락, 거래량 등)
// ScrollableTabRow(
// selectedTabIndex = selectedTab.ordinal,
// backgroundColor = Color.Transparent,
// contentColor = Color.Black,
// edgePadding = 0.dp,
// indicator = { tabPositions ->
// TabRowDefaults.Indicator(
// Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]),
// color = Color(0xFFE03E2D)
// )
// }
// ) {
// RankingType.values().forEach { type ->
// Tab(
// selected = selectedTab == type,
// onClick = { selectedTab = type },
// text = { Text(type.title, fontSize = 12.sp) }
// )
// }
// }
//
// Spacer(modifier = Modifier.height(8.dp))
//
// // [3] 랭킹 리스트
// if (isLoading) {
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// CircularProgressIndicator(strokeWidth = 2.dp)
// }
// } else {
// LazyColumn(modifier = Modifier.fillMaxSize()) {
// items(rankingList.withIndex().toList()) { (index, stock) ->
// MarketStockItemRow(index + 1, stock) {
// onStockSelect(stock.code, stock.name, isDomestic)
// }
// Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
// }
// }
// }
// }
//}
+62 -62
View File
@@ -1,62 +1,62 @@
package ui
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.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.RankingStock
import model.RankingType
import network.KisTradeService
// src/main/kotlin/ui/MarketStockItemRow.kt
@Composable
fun MarketStockItemRow(
rank: Int,
stock: RankingStock,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 순위 표시
Text(
text = rank.toString(),
modifier = Modifier.width(24.dp),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray
)
Column(modifier = Modifier.weight(1f)) {
Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1)
Text(stock.code, fontSize = 10.sp, color = Color.Gray)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L),
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
Text(
text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%",
fontSize = 11.sp,
color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
)
}
}
}
//package ui
//
//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.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.RankingStock
//import model.RankingType
//import network.KisTradeService
//
//
//// src/main/kotlin/ui/MarketStockItemRow.kt
//@Composable
//fun MarketStockItemRow(
// rank: Int,
// stock: RankingStock,
// onClick: () -> Unit
//) {
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .clickable { onClick() }
// .padding(vertical = 10.dp, horizontal = 4.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// // 순위 표시
// Text(
// text = rank.toString(),
// modifier = Modifier.width(24.dp),
// fontSize = 14.sp,
// fontWeight = FontWeight.Bold,
// color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray
// )
//
// Column(modifier = Modifier.weight(1f)) {
// Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1)
// Text(stock.code, fontSize = 10.sp, color = Color.Gray)
// }
//
// Column(horizontalAlignment = Alignment.End) {
// Text(
// text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L),
// fontSize = 13.sp,
// fontWeight = FontWeight.Bold
// )
// val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
// Text(
// text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%",
// fontSize = 11.sp,
// color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
// )
// }
// }
//}
+57 -57
View File
@@ -1,57 +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_prpr.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_prpr.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
)
}
}
}
}
}
}
}
//// 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_prpr.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_prpr.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
// )
// }
// }
// }
// }
// }
// }
//}
+43 -43
View File
@@ -1,43 +1,43 @@
// src/main/kotlin/ui/RealTimeTradeList.kt
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.RealTimeTrade
@Composable
fun RealTimeTradeList(tradeLogs: List<RealTimeTrade>) {
Column(modifier = Modifier.fillMaxSize()) {
// [1] 리스트 헤더 영역
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFEEEEEE)) // 연한 회색 배경
.padding(vertical = 4.dp)
) {
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
}
// [2] 실제 데이터 리스트
LazyColumn(modifier = Modifier.fillMaxSize()) {
// 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴)
items(tradeLogs) { trade ->
TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
}
}
}
}
//// src/main/kotlin/ui/RealTimeTradeList.kt
//package ui
//
//import androidx.compose.foundation.background
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items
//import androidx.compose.material.Divider
//import androidx.compose.material.Text
//import androidx.compose.runtime.Composable
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.text.style.TextAlign
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.unit.sp
//import model.RealTimeTrade
//
//@Composable
//fun RealTimeTradeList(tradeLogs: List<RealTimeTrade>) {
// Column(modifier = Modifier.fillMaxSize()) {
// // [1] 리스트 헤더 영역
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .background(Color(0xFFEEEEEE)) // 연한 회색 배경
// .padding(vertical = 4.dp)
// ) {
// Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
// Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
// Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
// Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
// }
//
// // [2] 실제 데이터 리스트
// LazyColumn(modifier = Modifier.fillMaxSize()) {
// // 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴)
// items(tradeLogs) { trade ->
// TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용
// Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
// }
// }
// }
//}
+294 -294
View File
@@ -1,302 +1,302 @@
package ui
import network.TradingDecision
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
// 아래 두 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.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import model.CandleData
import network.DartCodeManager
import network.KisTradeService
import network.KisWebSocketManager
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.collections.isNotEmpty
@Composable
fun StockDetailSection(
stockCode: String,
stockName: String,
holdingQuantity: String,
isDomestic: Boolean,
tradeService: KisTradeService,
wsManager: KisWebSocketManager,
onOrderSaved: (String) -> Unit,
completeTradingDecision: TradingDecision?,
min30 : MutableList<CandleData>,
daySummary : MutableList<CandleData>,
weekSummary : MutableList<CandleData>,
monthSummary : MutableList<CandleData>,
yearSummary : MutableList<CandleData>
) {
// 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) }
val todayOpen = remember(daySummary) {
daySummary.lastOrNull()?.stck_oprc ?: "0"
}
val previousClose = remember(daySummary) {
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
}
// 이전 종목 코드를 기억하기 위한 상태
var previousCode by remember { mutableStateOf("") }
var lastPrice by remember { mutableStateOf("0") }
// 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
LaunchedEffect(stockCode) {
if (stockCode.isEmpty()) return@LaunchedEffect
isLoading = true
// 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
if (previousCode.isNotEmpty()) {
wsManager.unsubscribeStock(previousCode)
}
wsManager.clearData()
wsManager.subscribeStock(stockCode)
previousCode = stockCode
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
coroutineScope {
launch {
wsManager.onPriceUpdate = {tradeLog ->
if (tradeLog.code.equals(stockCode)) {
val code = tradeLog.code
val price = tradeLog.price
wsManager.tradeLogs.add(tradeLog)
if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast()
// println("code $code ,price $price")
val currentPrice = price
if (chartData.isNotEmpty() && currentPrice != "0") {
val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val lastCandle = chartData.last()
// 현재 시간(분 단위) 확인
val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
if (lastCandle.stck_bsop_date != currentMinute) {
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
val newCandle = CandleData(
stck_bsop_date = currentMinute,
stck_oprc = currentPrice,
stck_hgpr = currentPrice,
stck_lwpr = currentPrice,
stck_prpr = currentPrice,
stck_cntg_hour = currentMinute,
cntg_vol = "1",
acml_tr_pbmn = "1",
)
// 최대 100개까지만 유지하여 성능 최적화
chartData = (chartData + newCandle).takeLast(100)
} else {
// 같은 분 내에서는 기존 마지막 캔들만 업데이트
val updatedCandle = lastCandle.copy(
stck_prpr = currentPrice,
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr,
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr
)
chartData = chartData.dropLast(1) + updatedCandle
}
}
lastPrice = currentPrice
}
}
}
launch {tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data ->
// println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
chartData = data
min30.clear()
min30.addAll(chartData)
}
.onFailure { error ->
// println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList()
}
}
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
daySummary.clear()
daySummary.addAll(it)
}
} // 최근 7일
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
weekSummary.clear()
weekSummary.addAll(it.takeLast(4))
// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
}
} // 최근 4주
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
monthSummary.clear()
monthSummary.addAll(it.takeLast(6))
yearSummary.clear()
yearSummary.addAll(it.takeLast(36))
}
}
launch {
DartCodeManager.getCorpCode(stockCode)?.let {
it.stockName = stockName
// NewsService.fetchAndIngestNews(it)
}
}
}
isLoading = false
}
// LaunchedEffect(latestPrice) {
// println("latestPrice >>> $latestPrice")
// if (chartData.isNotEmpty() && latestPrice != "0") {
// val latestPrice = latestPrice ?: "0"
// val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect
// val lastCandle = chartData.last()
//package ui
//
// // 현재 시간(분 단위) 확인
// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
//
// if (lastCandle.stck_bsop_date != currentMinute) {
// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
// val newCandle = CandleData(
// stck_bsop_date = currentMinute,
// stck_oprc = latestPrice,
// stck_hgpr = latestPrice,
// stck_lwpr = latestPrice,
// stck_prpr = latestPrice,
// stck_cntg_hour = currentMinute,
// cntg_vol = "1",
// acml_tr_pbmn = "1",
//
//import network.TradingDecision
//import androidx.compose.foundation.layout.*
//import androidx.compose.material.*
//import androidx.compose.runtime.*
//// 아래 두 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.unit.dp
//import androidx.compose.ui.unit.sp
//import kotlinx.coroutines.coroutineScope
//import kotlinx.coroutines.launch
//import model.CandleData
//import network.DartCodeManager
//import network.KisTradeService
//import network.KisWebSocketManager
//import java.time.LocalTime
//import java.time.format.DateTimeFormatter
//import kotlin.collections.isNotEmpty
//
//@Composable
//fun StockDetailSection(
// stockCode: String,
// stockName: String,
// holdingQuantity: String,
// isDomestic: Boolean,
// tradeService: KisTradeService,
// wsManager: KisWebSocketManager,
// onOrderSaved: (String) -> Unit,
// completeTradingDecision: TradingDecision?,
// min30 : MutableList<CandleData>,
// daySummary : MutableList<CandleData>,
// weekSummary : MutableList<CandleData>,
// monthSummary : MutableList<CandleData>,
// yearSummary : MutableList<CandleData>
//) {
//
//// 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) }
//
//
// val todayOpen = remember(daySummary) {
// daySummary.lastOrNull()?.stck_oprc ?: "0"
// }
// val previousClose = remember(daySummary) {
// if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
// }
//
//
//
// // 이전 종목 코드를 기억하기 위한 상태
// var previousCode by remember { mutableStateOf("") }
// var lastPrice by remember { mutableStateOf("0") }
//
//
// // 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
// LaunchedEffect(stockCode) {
// if (stockCode.isEmpty()) return@LaunchedEffect
//
// isLoading = true
//
// // 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
// if (previousCode.isNotEmpty()) {
// wsManager.unsubscribeStock(previousCode)
// }
// wsManager.clearData()
// wsManager.subscribeStock(stockCode)
// previousCode = stockCode
//
//
// // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
//
// coroutineScope {
// launch {
// wsManager.onPriceUpdate = {tradeLog ->
//
//
// if (tradeLog.code.equals(stockCode)) {
// val code = tradeLog.code
// val price = tradeLog.price
// wsManager.tradeLogs.add(tradeLog)
// if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast()
//// println("code $code ,price $price")
// val currentPrice = price
// if (chartData.isNotEmpty() && currentPrice != "0") {
// val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
// val lastCandle = chartData.last()
//
// // 현재 시간(분 단위) 확인
// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
//
// if (lastCandle.stck_bsop_date != currentMinute) {
// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
// val newCandle = CandleData(
// stck_bsop_date = currentMinute,
// stck_oprc = currentPrice,
// stck_hgpr = currentPrice,
// stck_lwpr = currentPrice,
// stck_prpr = currentPrice,
// stck_cntg_hour = currentMinute,
// cntg_vol = "1",
// acml_tr_pbmn = "1",
// )
// // 최대 100개까지만 유지하여 성능 최적화
// chartData = (chartData + newCandle).takeLast(100)
// } else {
// // 같은 분 내에서는 기존 마지막 캔들만 업데이트
// val updatedCandle = lastCandle.copy(
// stck_prpr = currentPrice,
// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr,
// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr
// )
// chartData = chartData.dropLast(1) + updatedCandle
// }
// }
// lastPrice = currentPrice
// }
//
// }
// }
// launch {tradeService.fetchChartData(stockCode, isDomestic)
// .onSuccess { data ->
//// println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
// chartData = data
// min30.clear()
// min30.addAll(chartData)
// }
// .onFailure { error ->
//// println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
// chartData = emptyList()
// }
// }
// launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
// daySummary.clear()
// daySummary.addAll(it)
// }
// } // 최근 7일
// launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
// weekSummary.clear()
// weekSummary.addAll(it.takeLast(4))
//// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
// }
// } // 최근 4주
// launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
// monthSummary.clear()
// monthSummary.addAll(it.takeLast(6))
// yearSummary.clear()
// yearSummary.addAll(it.takeLast(36))
// }
// }
// launch {
// DartCodeManager.getCorpCode(stockCode)?.let {
// it.stockName = stockName
//// NewsService.fetchAndIngestNews(it)
// }
// }
// }
// isLoading = false
// }
//
//
//
//// LaunchedEffect(latestPrice) {
//// println("latestPrice >>> $latestPrice")
//// if (chartData.isNotEmpty() && latestPrice != "0") {
//// val latestPrice = latestPrice ?: "0"
//// val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect
//// val lastCandle = chartData.last()
////
//// // 현재 시간(분 단위) 확인
//// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
////
//// if (lastCandle.stck_bsop_date != currentMinute) {
//// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
//// val newCandle = CandleData(
//// stck_bsop_date = currentMinute,
//// stck_oprc = latestPrice,
//// stck_hgpr = latestPrice,
//// stck_lwpr = latestPrice,
//// stck_prpr = latestPrice,
//// stck_cntg_hour = currentMinute,
//// cntg_vol = "1",
//// acml_tr_pbmn = "1",
//// )
//// // 최대 100개까지만 유지하여 성능 최적화
//// chartData = (chartData + newCandle).takeLast(100)
//// } else {
//// // 같은 분 내에서는 기존 마지막 캔들만 업데이트
//// val updatedCandle = lastCandle.copy(
//// stck_prpr = 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)) {
// // [상단] 종목명 및 상태 메시지
// Row(
// modifier = Modifier.fillMaxWidth(),
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically
// ) {
// StockHeader(
// name = stockName,
// code = stockCode,
// isDomestic = isDomestic,
// previousClose = previousClose,
// openPrice = lastPrice,
// resultMessage = resultMessage,
// resultMessageClear = {resultMessage = ""},
// isSuccess = isSuccess
// )
//
// // 실시간 가격 표시 (WebSocket 데이터)
// Column(horizontalAlignment = Alignment.End) {
// Text(
// text = "${lastPrice} 원",
// style = MaterialTheme.typography.h4,
// fontWeight = FontWeight.Bold,
// color = if (lastPrice?.contains("-") ?: false) Color.Blue else Color.Red
// )
// // 최대 100개까지만 유지하여 성능 최적화
// chartData = (chartData + newCandle).takeLast(100)
// 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(4.dp))
// // [중앙] 캔들 차트 (Card 내부)
// Card(
// modifier = Modifier.fillMaxWidth().height(320.dp),
// backgroundColor = Color(0xFF121212)
// ) {
// if (isLoading) {
// Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
// } else {
// // 같은 분 내에서는 기존 마지막 캔들만 업데이트
// val updatedCandle = lastCandle.copy(
// stck_prpr = 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
// CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
// }
// }
//
// Spacer(modifier = Modifier.height(4.dp))
//
//
//
// // [하단] 실시간 체결 내역 및 주문 섹션
// Row(modifier = Modifier.weight(1f)) {
// // 실시간 체결 리스트
// Column(modifier = Modifier.weight(1f)) {
// Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// RealTimeTradeList(wsManager.tradeLogs)
// }
//
// Spacer(modifier = Modifier.width(12.dp))
//
// // 주문 섹션 (인자 간소화)
// Column(modifier = Modifier.weight(0.6f)) {
// IntegratedOrderSection(
// stockCode = stockCode,
// stockName = stockName,
// isDomestic = isDomestic,
// currentPrice = lastPrice,
// holdingQuantity = holdingQuantity,
// tradeService = tradeService,
// onOrderSaved = onOrderSaved,
// onOrderResult = { msg, success ->
// resultMessage = msg
// isSuccess = success
// },
// completeTradingDecision
// )
// chartData = chartData.dropLast(1) + updatedCandle
// }
// }
// }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// [상단] 종목명 및 상태 메시지
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StockHeader(
name = stockName,
code = stockCode,
isDomestic = isDomestic,
previousClose = previousClose,
openPrice = lastPrice,
resultMessage = resultMessage,
resultMessageClear = {resultMessage = ""},
isSuccess = isSuccess
)
// 실시간 가격 표시 (WebSocket 데이터)
Column(horizontalAlignment = Alignment.End) {
Text(
text = "${lastPrice}",
style = MaterialTheme.typography.h4,
fontWeight = FontWeight.Bold,
color = if (lastPrice?.contains("-") ?: false) 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(4.dp))
// [중앙] 캔들 차트 (Card 내부)
Card(
modifier = Modifier.fillMaxWidth().height(320.dp),
backgroundColor = Color(0xFF121212)
) {
if (isLoading) {
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
} else {
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
}
}
Spacer(modifier = Modifier.height(4.dp))
// [하단] 실시간 체결 내역 및 주문 섹션
Row(modifier = Modifier.weight(1f)) {
// 실시간 체결 리스트
Column(modifier = Modifier.weight(1f)) {
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
RealTimeTradeList(wsManager.tradeLogs)
}
Spacer(modifier = Modifier.width(12.dp))
// 주문 섹션 (인자 간소화)
Column(modifier = Modifier.weight(0.6f)) {
IntegratedOrderSection(
stockCode = stockCode,
stockName = stockName,
isDomestic = isDomestic,
currentPrice = lastPrice,
holdingQuantity = holdingQuantity,
tradeService = tradeService,
onOrderSaved = onOrderSaved,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
},
completeTradingDecision
)
}
}
}
}
@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)
}
}
}
//}
//
//@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)
// }
// }
//}
+74 -74
View File
@@ -1,74 +1,74 @@
// src/main/kotlin/ui/StockHeader.kt
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
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
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun StockHeader(
name: String,
code: String,
isDomestic: Boolean,
previousClose: String, // 추가: 전일 종가
openPrice: String, // 추가: 금일 시가
resultMessage: String,
resultMessageClear : ()->Unit,
isSuccess: Boolean
) {
Column(modifier = Modifier.wrapContentWidth()) {
// [1] 알림 메시지 영역 (기존 동일)
if (resultMessage.isNotEmpty()) {
Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
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)
}
}
// [2] 종목명 및 정보
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
shape = RoundedCornerShape(4.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)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "($code)", 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)
}
}
//// src/main/kotlin/ui/StockHeader.kt
//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
//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
//
//@OptIn(ExperimentalMaterialApi::class)
//@Composable
//fun StockHeader(
// name: String,
// code: String,
// isDomestic: Boolean,
// previousClose: String, // 추가: 전일 종가
// openPrice: String, // 추가: 금일 시가
// resultMessage: String,
// resultMessageClear : ()->Unit,
// isSuccess: Boolean
//) {
// Column(modifier = Modifier.wrapContentWidth()) {
// // [1] 알림 메시지 영역 (기존 동일)
// if (resultMessage.isNotEmpty()) {
// Surface(
// color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
// 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)
// }
// }
//
// // [2] 종목명 및 정보
// Row(verticalAlignment = Alignment.CenterVertically) {
// Surface(
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
// shape = RoundedCornerShape(4.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)
// Spacer(modifier = Modifier.width(6.dp))
// Text(text = "($code)", 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)
// }
//}
+63 -63
View File
@@ -1,63 +1,63 @@
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.RankingStock
import model.RealTimeTrade
import model.StockHolding
import model.TradeType
import network.KisTradeService
import util.MarketUtil
@Composable
fun TradeLogRow(trade: RealTimeTrade) {
val color = when (trade.type) {
TradeType.BUY -> Color(0xFFE03E2D)
TradeType.SELL -> Color(0xFF0E62CF)
else -> Color.DarkGray
}
// 대량 체결(예: 1000주 이상) 시 연한 배경색 강조
val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000
val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent
Row(
modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center)
Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End)
Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End)
Text(
text = trade.volume,
modifier = Modifier.weight(1f),
fontSize = 12.sp,
fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal,
color = color,
textAlign = TextAlign.End
)
}
}
//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.RankingStock
//import model.RealTimeTrade
//import model.StockHolding
//import model.TradeType
//import network.KisTradeService
//import util.MarketUtil
//
//@Composable
//fun TradeLogRow(trade: RealTimeTrade) {
// val color = when (trade.type) {
// TradeType.BUY -> Color(0xFFE03E2D)
// TradeType.SELL -> Color(0xFF0E62CF)
// else -> Color.DarkGray
// }
//
// // 대량 체결(예: 1000주 이상) 시 연한 배경색 강조
// val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000
// val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent
//
// Row(
// modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center)
// Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End)
// Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End)
// Text(
// text = trade.volume,
// modifier = Modifier.weight(1f),
// fontSize = 12.sp,
// fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal,
// color = color,
// textAlign = TextAlign.End
// )
// }
//}
+30
View File
@@ -395,3 +395,33 @@ fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? =
// }
}
}
fun findLongestCommonSubstring(s1: String, s2: String): String {
if (s1.isEmpty() || s2.isEmpty()) return ""
var longest = ""
// 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
val reference = if (s1.length <= s2.length) s1 else s2
val target = if (s1.length <= s2.length) s2 else s1
for (i in reference.indices) {
for (j in (i + longest.length + 1)..reference.length) {
val sub = reference.substring(i, j)
if (target.contains(sub)) {
if (sub.length > longest.length) {
longest = sub
}
} else {
// target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
break
}
}
}
return longest
}
fun getRemaining(original: String, common: String): String {
if (common.isEmpty()) return original
// 가장 처음 발견되는 공통 문자열을 한 번만 제거
return original.replaceFirst(common, "").trim()
}