This commit is contained in:
2026-01-13 16:04:25 +09:00
parent d4770af62f
commit 2bb94e2856
23 changed files with 1406 additions and 1103 deletions
+11 -26
View File
@@ -21,25 +21,26 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.KisSession
import model.RealTimeTrade
import network.AiService
@Composable
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTimeTrade>) {
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// 1. 모델 경로 유효성 체크
val isModelConfigured = remember {
val path = util.AppConfigManager.modelPath
// KisSession의 전역 설정을 참조
val isModelConfigured = remember(KisSession.config.modelPath) {
val path = KisSession.config.modelPath
path.isNotEmpty() && java.io.File(path).exists()
}
Card(
elevation = 2.dp,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE)
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -49,38 +50,22 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTim
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
)
Spacer(Modifier.weight(1f))
// 2. 경로가 정상일 때만 버튼 활성화
Button(
onClick = {
scope.launch {
isLoading = true
aiOpinion = "Gemma가 데이터를 읽고 있습니다..."
aiOpinion = "데이터 분석 중..."
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
isLoading = false
}
},
enabled = isModelConfigured && !isLoading, // 유효성 체크 반영
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.White,
disabledBackgroundColor = Color(0xFFE0E0E0)
)
enabled = isModelConfigured && !isLoading
) {
Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp)
}
}
if (!isModelConfigured) {
Text(
"설정에서 .gguf 모델 파일을 먼저 등록해주세요.",
color = Color.Red,
fontSize = 11.sp,
modifier = Modifier.padding(top = 4.dp)
)
} else {
Divider(Modifier.padding(vertical = 8.dp))
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
}
Divider(Modifier.padding(vertical = 8.dp))
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
}
}
}
+146
View File
@@ -0,0 +1,146 @@
// 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.runtime.*
import androidx.compose.ui.Modifier
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,
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
) {
var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
var isLoading by remember { mutableStateOf(false) }
// 화면 진입 시 및 갱신 시 데이터 로드
LaunchedEffect(Unit) {
isLoading = true
tradeService.fetchIntegratedBalance().onSuccess {
balanceData = it
}.onFailure {
println("❌ 잔고 로드 실패: ${it.localizedMessage}")
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "나의 자산",
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.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)
}
}
}
}
}
}
@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) {
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 = androidx.compose.ui.Alignment.End) {
Text("${holding.currentPrice}")
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%",
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
else androidx.compose.ui.graphics.Color.DarkGray,
fontSize = 12.sp
)
}
}
}
}
+9 -6
View File
@@ -22,19 +22,22 @@ fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
val spacing = candleWidth * 0.2f // 캔들 사이 간격
// 1. 가격 범위 계산 (스케일링용)
val maxPrice = data.maxOf { it.stck_hgpr.toDouble() }
val minPrice = data.minOf { it.stck_lwpr.toDouble() }
val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
val priceRange = maxPrice - minPrice
// priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지
fun getY(price: Double): Float {
if (priceRange == 0.0) return height / 2f
return (height - ((price - minPrice) / priceRange * height)).toFloat()
}
// 루프 내부에서도 동일하게 적용
data.forEachIndexed { index, candle ->
val open = candle.stck_oprc.toDouble()
val close = candle.stck_clpr.toDouble()
val high = candle.stck_hgpr.toDouble()
val low = candle.stck_lwpr.toDouble()
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
val close = candle.stck_clpr.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)
+29 -253
View File
@@ -1,302 +1,78 @@
// src/main/kotlin/ui/DashboardScreen.kt
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.RankingType
import model.StockHolding
import model.KisSession
import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil
@Composable
fun DashboardScreen(config: AppConfig, token: String) {
val wsManager = remember { KisWebSocketManager(config.isSimulation) }
val tradeService = remember { KisTradeService(config.isSimulation) }
fun DashboardScreen() {
val tradeService = remember { KisTradeService() }
val wsManager = remember { KisWebSocketManager() }
// 전역 상태: 현재 선택된 종목
// 전역 상태: 현재 선택된 종목 정보
var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") }
var isDomestic by remember { mutableStateOf(true) }
// 잔고 데이터 상태
var holdings by remember { mutableStateOf<List<StockHolding>>(emptyList()) }
var summary by remember { mutableStateOf<BalanceSummary?>(null) }
// 초기 데이터 로드 및 웹소켓 연결
// 초기 웹소켓 연결
LaunchedEffect(Unit) {
val approvalKey = tradeService.fetchApprovalKey(config.appKey, config.secretKey)
approvalKey?.let { wsManager.connect(it) }
tradeService.fetchBalance(token, config.appKey, config.secretKey, config.accountNo)
.onSuccess {
holdings = it.output1
summary = it.output2.firstOrNull()
}
wsManager.connect()
}
// 메인 3분할 레이아웃
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 나의 자산 및 잔고
// [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) {
Text("나의 잔고", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
BalanceSummaryCard(summary)
Spacer(modifier = Modifier.height(8.dp))
MyStockList(holdings) { code, name ->
BalanceSection(tradeService) { code, name, isDom ->
selectedStockCode = code
selectedStockName = name
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
}
}
VerticalDivider()
// [중앙 45%] 실시간 차트 및 주문 (가장 중요)
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) {
// [중앙 45%] 실시간 정보 및 주문
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) {
if (selectedStockCode.isNotEmpty()) {
StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager)
StockDetailSection(
stockCode = selectedStockCode,
stockName = selectedStockName,
isDomestic = isDomestic,
tradeService = tradeService,
wsManager = wsManager
)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray)
Text("분석할 종목을 선택하세요", color = Color.Gray)
}
}
}
VerticalDivider()
// [우측 30%] 시장 추천 리스트 (탭 방식)
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
Text("시장 추천 TOP 20", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
RecommendationTabs(config, token) { code, name ->
MarketSection(tradeService) { code, name, isDom ->
selectedStockCode = code
selectedStockName = name
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
}
}
}
}
@Composable
fun StockItemRow(stock: StockHolding) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stock.prdt_name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(stock.pdno, fontSize = 12.sp, color = Color.Gray)
}
Column(horizontalAlignment = Alignment.End) {
Text("${stock.prpr}", fontWeight = FontWeight.Bold)
// 수익률에 따른 색상 처리 (웹 소스 format.color 로직 이식)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = when {
rate > 0 -> Color(0xFFE03E2D) // 웹 소스의 빨간색
rate < 0 -> Color(0xFF0E62CF) // 웹 소스의 파란색
else -> Color.DarkGray
}
Text(
text = "${if(rate > 0) "▲" else if(rate < 0) "▼" else ""} ${stock.evlu_pfls_rt}%",
color = color,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun RankingItemRow(
index: Int, // 순위 표시를 위해 index 추가
rank: RankingStock,
isDomestic: Boolean,
type: RankingType,
onClick: () -> Unit
) {
val displayColor = when {
type == RankingType.FALL -> Color(0xFF0E62CF) // 하락 탭은 무조건 파랑
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 > 0 -> Color(0xFFE03E2D) // 그 외 양수면 빨강
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 < 0 -> Color(0xFF0E62CF) // 음수면 파랑
else -> Color.DarkGray
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = 0.dp,
backgroundColor = Color.White
) {
Row(
modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// [1] 순위 표시 (1~20)
Text(
text = "${index + 1}",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (index < 3) displayColor else Color.Gray, // 1~3위 강조
modifier = Modifier.width(24.dp)
)
// [2] 종목명 및 코드
Column(modifier = Modifier.weight(1f)) {
Text(
text = rank.hts_kor_alph_nm,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = rank.mkrtc_objt_iscd,
fontSize = 11.sp,
color = Color.Gray
)
}
// [3] 등락률 배지
Surface(
color = displayColor.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rank.prdy_ctrt.toDouble() > 0) "+" else ""}${rank.prdy_ctrt}%",
color = displayColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
@Composable
fun VerticalDivider(modifier: Modifier = Modifier) {
Box(modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}
@Composable
fun BalanceSummaryCard(summary: BalanceSummary?) {
Card(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
backgroundColor = Color(0xFFF8F9FA) // 가벼운 배경색
) {
Column(modifier = Modifier.padding(20.dp)) {
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = Color.Gray)
Text(
text = "${summary?.tot_evlu_amt ?: "0"}",
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(8.dp))
val profitRate = summary?.evlu_pfls_rt?.toDoubleOrNull() ?: 0.0
val profitColor = if (profitRate > 0) Color(0xFFE03E2D) else if (profitRate < 0) Color(0xFF0E62CF) else Color.DarkGray
Row(verticalAlignment = Alignment.CenterVertically) {
Text("실현 수익률: ", style = MaterialTheme.typography.body2)
Text(
text = "${if (profitRate > 0) "+" else ""}$profitRate%",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
color = profitColor
)
}
}
}
}
@Composable
fun StockItemRow(stock: StockHolding, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onClick() },
elevation = 2.dp,
shape = RoundedCornerShape(4.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. 종목명 및 코드 (왼쪽)
Column(modifier = Modifier.weight(1.2f)) {
Text(
text = stock.prdt_name,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stock.pdno,
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// 2. 보유 수량 및 현재가 (중앙)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text("${stock.hldg_qty}", style = MaterialTheme.typography.body2)
Text(
text = "${stock.prpr}",
style = MaterialTheme.typography.caption,
color = Color.DarkGray
)
}
// 3. 수익률 (오른쪽)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
Box(
modifier = Modifier.weight(0.8f),
contentAlignment = Alignment.CenterEnd
) {
Surface(
color = color.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${stock.evlu_pfls_rt}%",
color = color,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}
+101
View File
@@ -0,0 +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)
}
}
}
}
}
+62
View File
@@ -0,0 +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
)
}
}
}
+46 -65
View File
@@ -33,80 +33,61 @@ import kotlin.collections.isNotEmpty
@Composable
fun OrderSection(
config: AppConfig,
token: String,
stockCode: String,
currentPrice: String,
onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달
onOrderResult: (String, Boolean) -> Unit
) {
val scope = rememberCoroutineScope() // 에러 해결: scope 정의
val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의
val scope = rememberCoroutineScope()
val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전
var orderQty by remember { mutableStateOf("1") }
var orderPrice by remember { mutableStateOf("0") } // 0은 시장가
var isSubmitting by remember { mutableStateOf(false) }
var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF8F9FA))
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// 수량 입력
OutlinedTextField(
value = orderQty,
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
label = { Text("수량", fontSize = 10.sp) },
modifier = Modifier.width(100.dp).height(50.dp),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
// 가격 입력 (시장가 체크박스 기능 포함 가능)
OutlinedTextField(
value = if(orderPrice == "0") "시장가" else orderPrice,
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
label = { Text("가격", fontSize = 10.sp) },
modifier = Modifier.weight(1f).height(50.dp),
singleLine = true
)
}
Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) {
Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp)
OutlinedTextField(
value = orderQty,
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
label = { Text("수량") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = if(orderPrice == "0") "시장가" else orderPrice,
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
label = { Text("가격") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// 매수 버튼
Button(
onClick = {
isSubmitting = true
scope.launch {
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, true)
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) }
isSubmitting = false
}
},
modifier = Modifier.weight(1f).height(45.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)),
enabled = !isSubmitting
) {
Text("현금매수", color = Color.White, fontWeight = FontWeight.Bold)
}
Button(
onClick = {
scope.launch {
// KisSession을 사용하는 postOrder 호출
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true)
.onSuccess { onOrderResult(it, true) }
.onFailure { onOrderResult(it.message ?: "에러", false) }
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
) {
Text("매수", color = Color.White)
}
// 매도 버튼
Button(
onClick = {
isSubmitting = true
scope.launch {
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, false)
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) }
isSubmitting = false
}
},
modifier = Modifier.weight(1f).height(45.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)),
enabled = !isSubmitting
) {
Text("현금매도", color = Color.White, fontWeight = FontWeight.Bold)
}
Button(
onClick = {
scope.launch {
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false)
.onSuccess { onOrderResult(it, true) }
.onFailure { onOrderResult(it.message ?: "에러", false) }
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
) {
Text("매도", color = Color.White)
}
}
}
+43
View File
@@ -0,0 +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)
}
}
}
}
+152 -152
View File
@@ -1,152 +1,152 @@
package ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.RankingType
import model.StockHolding
import network.KisTradeService
import util.MarketUtil
@Composable
fun RecommendationTabs(
config: AppConfig,
token: String,
onSelect: (String, String) -> Unit
) {
var isDomestic by remember { mutableStateOf(true) }
var selectedType by remember { mutableStateOf(RankingType.RISE) }
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
val tradeService = remember { KisTradeService(config.isSimulation) }
val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
// 데이터 로드 로직
LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
errorMessage = null // 로딩 시작 시 에러 초기화
if (isDomestic) {
if (isKoreaOpen) {
tradeService.fetchMarketRanking(token, config, selectedType, true)
.onSuccess { rankingList = it.take(20) }
.onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
} else {
tradeService.fetchDomesticPreviousDayRanking(token, config)
.onSuccess { rankingList = it }
.onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
}
} else {
tradeService.fetchOverseasRanking(token, config)
.onSuccess { rankingList = it }
.onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
}
}
Column(modifier = Modifier.fillMaxSize()) {
// [1] 국내/해외 전환 버튼 (항상 노출)
Row(Modifier.fillMaxWidth().padding(8.dp)) {
MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
Spacer(Modifier.width(8.dp))
MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
}
// [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
ScrollableTabRow(
selectedTabIndex = selectedType.ordinal,
edgePadding = 8.dp,
backgroundColor = Color.White,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
)
}
) {
RankingType.values().forEach { type ->
Tab(
selected = selectedType == type,
onClick = { selectedType = type },
text = { Text(type.title, fontSize = 12.sp) }
)
}
}
// [3] 장외 시간 안내 바
if (isDomestic && !isKoreaOpen) {
Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
Text(
"현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
fontSize = 11.sp, modifier = Modifier.padding(8.dp)
)
}
}
// [4] 추천 리스트 영역
Box(modifier = Modifier.weight(1f)) {
if (errorMessage != null) {
// 에러 발생 시 안내
Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
Text(errorMessage!!, color = Color.Gray)
Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
}
} else if (rankingList.isEmpty()) {
// 로딩 중
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
// [성공] 리스트 노출
LazyColumn {
itemsIndexed(rankingList) { index, stock ->
RankingItemRow(index, stock, isDomestic, selectedType) {
onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
}
}
}
}
}
}
}
@Composable
fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
colors = ButtonDefaults.outlinedButtonColors(
backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
),
modifier = Modifier.height(36.dp),
border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
) {
Text(
text = title,
color = if (isSelected) activeColor else Color.Gray,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
//package ui
//
//
//import androidx.compose.foundation.BorderStroke
//import androidx.compose.foundation.background
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
//import androidx.compose.foundation.lazy.itemsIndexed
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material.*
//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
//import androidx.compose.runtime.*
//import io.ktor.client.engine.cio.CIO
//// 아래 두 import가 'delegate' 에러를 해결합니다.
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.setValue
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.graphics.Color
//import androidx.compose.ui.text.font.FontWeight
//import androidx.compose.ui.text.style.TextOverflow
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.unit.sp
//import kotlinx.coroutines.launch
//import model.AppConfig
//import model.BalanceSummary
//import model.RankingStock
//import model.RankingType
//import model.StockHolding
//import network.KisTradeService
//import util.MarketUtil
//
//@Composable
//fun RecommendationTabs(
// config: AppConfig,
// token: String,
// onSelect: (String, String) -> Unit
//) {
// var isDomestic by remember { mutableStateOf(true) }
// var selectedType by remember { mutableStateOf(RankingType.RISE) }
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
//
// val tradeService = remember { KisTradeService(config.isSimulation) }
// val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
// var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
//
// // 데이터 로드 로직
// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
// errorMessage = null // 로딩 시작 시 에러 초기화
// if (isDomestic) {
// if (isKoreaOpen) {
// tradeService.fetchMarketRanking(token, config, selectedType, true)
// .onSuccess { rankingList = it.take(20) }
// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
// } else {
// tradeService.fetchDomesticPreviousDayRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
// }
// } else {
// tradeService.fetchOverseasRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
// }
// }
//
// Column(modifier = Modifier.fillMaxSize()) {
// // [1] 국내/해외 전환 버튼 (항상 노출)
// Row(Modifier.fillMaxWidth().padding(8.dp)) {
// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
// Spacer(Modifier.width(8.dp))
// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
// }
//
// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
// ScrollableTabRow(
// selectedTabIndex = selectedType.ordinal,
// edgePadding = 8.dp,
// backgroundColor = Color.White,
// indicator = { tabPositions ->
// TabRowDefaults.Indicator(
// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
// )
// }
// ) {
// RankingType.values().forEach { type ->
// Tab(
// selected = selectedType == type,
// onClick = { selectedType = type },
// text = { Text(type.title, fontSize = 12.sp) }
// )
// }
// }
//
// // [3] 장외 시간 안내 바
// if (isDomestic && !isKoreaOpen) {
// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
// Text(
// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
// fontSize = 11.sp, modifier = Modifier.padding(8.dp)
// )
// }
// }
//
// // [4] 추천 리스트 영역
// Box(modifier = Modifier.weight(1f)) {
// if (errorMessage != null) {
// // 에러 발생 시 안내
// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
// Text(errorMessage!!, color = Color.Gray)
// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
// }
// } else if (rankingList.isEmpty()) {
// // 로딩 중
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// CircularProgressIndicator()
// }
// } else {
// // [성공] 리스트 노출
// LazyColumn {
// itemsIndexed(rankingList) { index, stock ->
// RankingItemRow(index, stock, isDomestic, selectedType) {
// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
// }
// }
// }
// }
// }
// }
//}
//
//@Composable
//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
// OutlinedButton(
// onClick = onClick,
// colors = ButtonDefaults.outlinedButtonColors(
// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
// ),
// modifier = Modifier.height(36.dp),
// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
// ) {
// Text(
// text = title,
// color = if (isSelected) activeColor else Color.Gray,
// fontSize = 12.sp,
// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
// )
// }
//}
+65 -105
View File
@@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.DragData
@@ -14,151 +12,113 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.onExternalDrag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.KisSession
import network.KisAuthService
import network.KisTradeService
import org.jetbrains.exposed.sql.deleteAll
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog // 파일 선택기용
// src/main/kotlin/ui/SettingsScreen.kt
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SettingsScreen(
initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요
onAuthSuccess: (AppConfig, String) -> Unit
) {
fun SettingsScreen(onAuthSuccess: () -> Unit) {
val scope = rememberCoroutineScope()
val authService = remember { KisAuthService() }
var config by remember { mutableStateOf(KisSession.config) }
var statusMessage by remember { mutableStateOf("정보를 입력하세요.") }
// 화면 입력 상태값
var appKey by remember { mutableStateOf(initialConfig.appKey) }
var secretKey by remember { mutableStateOf(initialConfig.secretKey) }
var accountNo by remember { mutableStateOf(initialConfig.accountNo) }
var isSimulation by remember { mutableStateOf(initialConfig.isSimulation) }
var modelPath by remember { mutableStateOf(initialConfig.modelPath ?: "") } // AI 모델 경로
var statusMessage by remember { mutableStateOf("설정 정보를 입력하세요.") }
var isLoading by remember { mutableStateOf(false) }
// 계좌번호 입력 시 데이터 자동 로드 함수
fun checkAndLoadConfig(accountNo: String, isReal: Boolean) {
val loaded = DatabaseFactory.findConfigByAccount(accountNo)
if (loaded != null) {
config = loaded
statusMessage = "✅ 기존 데이터를 불러왔습니다."
}
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
item {
Text("API 및 계좌 설정", style = MaterialTheme.typography.h6)
OutlinedTextField(value = appKey, onValueChange = { appKey = it }, label = { Text("App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = secretKey, onValueChange = { secretKey = it }, label = { Text("Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
OutlinedTextField(value = accountNo, onValueChange = { accountNo = it }, label = { Text("계좌번호") }, modifier = Modifier.fillMaxWidth())
Text("거래 방식 선택", style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it })
Text("모의투자 서버 사용")
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) })
Text("실전투자")
Spacer(Modifier.width(16.dp))
RadioButton(selected = config.isSimulation, onClick = { config = config.copy(isSimulation = true) })
Text("모의투자")
}
Divider(Modifier.padding(vertical = 12.dp))
// 실전 3종 입력
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.realAccountNo, onValueChange = {
config = config.copy(realAccountNo = it)
if(it.length >= 8) checkAndLoadConfig(it, true)
}, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(16.dp))
// 모의 3종 입력
Text("모의투자 정보", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
config = config.copy(vtsAccountNo = it)
if(it.length >= 8) checkAndLoadConfig(it, false)
}, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Divider(Modifier.padding(vertical = 16.dp))
// --- 추가된 AI 모델 설정 섹션 ---
Text("AI 모델 설정 (Gemma-2-9b)", style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = modelPath,
onValueChange = { modelPath = it },
label = { Text("GGUF 모델 경로") },
modifier = Modifier.weight(1f),
placeholder = { Text("파일을 선택하거나 드래그하세요") }
)
IconButton(onClick = {
val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("GGUF 모델", "gguf")
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
modelPath = chooser.selectedFile.absolutePath
}
}) {
Icon(Icons.Default.FolderOpen, contentDescription = "파일 선택")
}
}
// 드래그 앤 드롭 영역
// AI 모델 경로 및 드래그 앤 드롭
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.padding(top = 8.dp)
.border(1.dp, Color.LightGray, RoundedCornerShape(8.dp))
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
if (path?.endsWith(".gguf") == true) modelPath = path
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path)
}
}),
contentAlignment = Alignment.Center
) {
Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray)
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(Modifier.height(24.dp))
// 저장 및 접속 버튼
Button(
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = !isLoading,
onClick = {
isLoading = true
scope.launch {
// 1. 새로운 설정 객체 생성 (순서 주의: isSimulation 다음 modelPath)
val config = AppConfig(
appKey = appKey.trim(),
secretKey = secretKey.trim(),
accountNo = accountNo.trim(),
isSimulation = isSimulation,
modelPath = modelPath
)
// isLoading = true
// 1. KisSession.config 업데이트 및 DB 저장
KisSession.config = config
DatabaseFactory.saveConfig(config)
val authService = KisAuthService()
val tradeService = KisTradeService()
val authSuccess = authService.refreshAllTokens()
val wsKeySuccess = tradeService.refreshWebsocketKey()
transaction {
ConfigTable.deleteAll()
ConfigTable.insert {
it[ConfigTable.appKey] = config.appKey
it[ConfigTable.secretKey] = config.secretKey
it[ConfigTable.accountNo] = config.accountNo
it[ConfigTable.isSimulation] = config.isSimulation
it[ConfigTable.modelPath] = config.modelPath
}
if (authSuccess && wsKeySuccess) {
statusMessage = "✅ 인증 성공! LLM 시작 중..."
onAuthSuccess()
} else {
statusMessage = "❌ 인증 실패. 키 정보를 확인하세요."
}
statusMessage = "인증 토큰 발급 시도 중..."
authService.fetchAccessToken(appKey, secretKey, isSimulation)
.onSuccess { response ->
statusMessage = "✅ 인증 성공!"
onAuthSuccess(config, response.access_token)
}.onFailure {
statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}"
}
isLoading = false
// isLoading = false
}
}
) {
if (isLoading) {
// [수정된 프로그래스 바] size -> Modifier.size
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text("설정 저장 및 접속 시작")
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(statusMessage, color = if (statusMessage.contains("")) Color.Green else Color.Gray)
) { Text("설정 저장 및 실행") }
Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
+75 -108
View File
@@ -34,149 +34,116 @@ import network.KisWebSocketManager
import kotlin.collections.isNotEmpty
@Composable
fun StockDetailArea(
config: AppConfig,
token: String,
code: String,
name: String,
wsManager: KisWebSocketManager // 매니저 수신
fun StockDetailSection(
stockCode: String,
stockName: String,
isDomestic: Boolean,
tradeService: KisTradeService,
wsManager: KisWebSocketManager
) {
val currentPrice by wsManager.currentPrice
val priceColor by wsManager.priceChangeColor
val tradeLogs = wsManager.tradeLogs // Manager의 상태를 직접 참조
val tradeService = remember { KisTradeService(config.isSimulation) }
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) }
LaunchedEffect(code) {
if (code.isEmpty()) return@LaunchedEffect
// 이전 종목 코드를 기억하기 위한 상태
var previousCode by remember { mutableStateOf("") }
// 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
LaunchedEffect(stockCode) {
if (stockCode.isEmpty()) return@LaunchedEffect
isLoading = true
if (code.isNotEmpty()) {
// 기존 종목 구독 해지 및 새 종목 구독 메시지 전송
// (KisWebSocketManager에 해당 기능을 하는 함수를 만들어서 호출)
wsManager.subscribeStock(code)
}
// 종목 코드 판별 (숫자 6자리면 국내, 아니면 해외로 간주)
val isDomestic = code.all { it.isDigit() } && code.length == 6
val result = if (isDomestic) {
tradeService.fetchChartData(token, config.appKey, config.secretKey, code)
.map { it.output2.reversed() }
} else {
// 해외 주식 처리 (우선 NAS 나스닥 기준으로 호출)
tradeService.fetchOverseasChartData(token, config.appKey, config.secretKey, code)
// 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
if (previousCode.isNotEmpty()) {
wsManager.unsubscribeStock(previousCode)
}
wsManager.subscribeStock(stockCode)
previousCode = stockCode
result.onSuccess { chartData = it }
.onFailure { println("차트 로드 실패: ${it.message}") }
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력
chartData = data
}
.onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList()
}
isLoading = false
}
LaunchedEffect(resultMessage) {
if (resultMessage.isNotEmpty()) {
delay(3000)
resultMessage = ""
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
LaunchedEffect(latestPrice) {
println("latestPrice $latestPrice")
if (chartData.isNotEmpty() && latestPrice != "0") {
// 마지막 캔들 정보 업데이트
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
val lastCandle = chartData.last()
val updatedCandle = lastCandle.copy(
stck_clpr = latestPrice,
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
)
chartData = chartData.dropLast(1) + updatedCandle
println("chartData.size $chartData.size")
}
}
Column(modifier = Modifier.fillMaxSize()) {
// [상단 정보] 국내/해외 구분 배지 추가
if (resultMessage.isNotEmpty()) {
Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
) {
Text(
text = resultMessage,
color = Color.White,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
fontSize = 12.sp
)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// [상단] 종목명 및 상태 메시지
StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess)
Row(verticalAlignment = Alignment.CenterVertically) {
val isDomestic = code.all { it.isDigit() } && code.length == 6
Badge(backgroundColor = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)) {
Text(if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Text(name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
Text(" ($code)", color = Color.Gray)
}
Spacer(modifier = Modifier.height(16.dp))
// [차트 영역] CandleChart 컴포저블 재사용
// [중앙] 캔들 차트 (Card 내부)
Card(
modifier = Modifier.fillMaxWidth().height(350.dp),
modifier = Modifier.fillMaxWidth().height(300.dp),
backgroundColor = Color(0xFF121212)
) {
if (isLoading) {
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
} else if (chartData.isNotEmpty()) {
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
} else {
Box(contentAlignment = Alignment.Center) { Text("데이터가 없습니다.", color = Color.Gray) }
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
}
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// [중앙 하단] AI 투자 전략
AiAnalysisView(
stockName = name,
stockName = stockName,
currentPrice = wsManager.currentPrice.value,
trades = wsManager.tradeLogs
)
Spacer(modifier = Modifier.height(16.dp))
// 웹 소스 스타일의 주문 박스
// Card(modifier = Modifier.fillMaxWidth(), backgroundColor = Color(0xFFF8F9FA)) {
// Column(modifier = Modifier.padding(16.dp)) {
// Text("주문 설정", fontWeight = FontWeight.Bold)
// // 수량 입력, 매수/매도 버튼 배치 (detail.html 참고)
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Button(onClick = { /* 매수 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFFE03E2D))) {
// Text("매수", color = Color.White)
// }
// Button(onClick = { /* 매도 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFF0E62CF))) {
// Text("매도", color = Color.White)
// }
// }
// }
// }
Column(modifier = Modifier.weight(0.4f)) {
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// 헤더 영역
Row(modifier = Modifier.fillMaxWidth().background(Color(0xFFEEEEEE)).padding(vertical = 4.dp)) {
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp)
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
Spacer(modifier = Modifier.height(12.dp))
// [하단] 실시간 체결 내역 및 주문 섹션
Row(modifier = Modifier.weight(1f)) {
// 실시간 체결 리스트
Column(modifier = Modifier.weight(1f)) {
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
RealTimeTradeList(wsManager.tradeLogs)
}
// 실시간 리스트
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tradeLogs) { trade ->
TradeLogRow(trade)
Divider(color = Color(0xFFF5F5F5))
Spacer(modifier = Modifier.width(12.dp))
// 주문 섹션 (인자 간소화)
OrderSection(
stockCode = stockCode,
currentPrice = wsManager.currentPrice.value,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
}
}
)
}
OrderSection(
config = config,
token = token,
stockCode = code,
currentPrice = currentPrice,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
}
)
}
}
+80
View File
@@ -0,0 +1,80 @@
// src/main/kotlin/ui/StockHeader.kt
package ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun StockHeader(
name: String,
code: String,
isDomestic: Boolean,
resultMessage: String,
isSuccess: Boolean
) {
Column(modifier = Modifier.fillMaxWidth()) {
// [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시)
if (resultMessage.isNotEmpty()) {
Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
) {
Text(
text = resultMessage,
color = Color.White,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
// [2] 종목명 및 국가 배지 영역
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
// 국가 구분 배지
Surface(
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
) {
Text(
text = if (isDomestic) "국내" else "해외",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
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)",
style = MaterialTheme.typography.body1,
color = Color.Gray
)
}
}
}