This commit is contained in:
2026-01-10 18:16:50 +09:00
parent 547a00b139
commit d4770af62f
28 changed files with 2406 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
package ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
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 androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.RealTimeTrade
import network.AiService
@Composable
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTimeTrade>) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// 1. 모델 경로 유효성 체크
val isModelConfigured = remember {
val path = util.AppConfigManager.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)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (isModelConfigured) "🤖 AI 투자 전략" else "⚠️ AI 설정 필요",
fontWeight = FontWeight.Bold,
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
)
Spacer(Modifier.weight(1f))
// 2. 경로가 정상일 때만 버튼 활성화
Button(
onClick = {
scope.launch {
isLoading = true
aiOpinion = "Gemma가 데이터를 읽고 있습니다..."
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
isLoading = false
}
},
enabled = isModelConfigured && !isLoading, // 유효성 체크 반영
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.White,
disabledBackgroundColor = Color(0xFFE0E0E0)
)
) {
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)
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
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 candleCount = data.size
val candleWidth = width / candleCount
val spacing = candleWidth * 0.2f // 캔들 사이 간격
// 1. 가격 범위 계산 (스케일링용)
val maxPrice = data.maxOf { it.stck_hgpr.toDouble() }
val minPrice = data.minOf { it.stck_lwpr.toDouble() }
val priceRange = maxPrice - minPrice
fun getY(price: Double): Float {
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 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)
)
}
}
}
+302
View File
@@ -0,0 +1,302 @@
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 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) }
// 전역 상태: 현재 선택된 종목
var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") }
// 잔고 데이터 상태
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()
}
}
// 메인 3분할 레이아웃
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 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 ->
selectedStockCode = code
selectedStockName = name
}
}
VerticalDivider()
// [중앙 45%] 실시간 차트 및 주문 (가장 중요)
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) {
if (selectedStockCode.isNotEmpty()) {
StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray)
}
}
}
VerticalDivider()
// [우측 30%] 시장 추천 리스트 (탭 방식)
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 ->
selectedStockCode = code
selectedStockName = name
}
}
}
}
@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)
)
}
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.StockHolding
import network.KisTradeService
@Composable
fun MyStockList(
holdings: List<StockHolding>,
onSelect: (code: String, name: String) -> Unit
) {
if (holdings.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("보유 종목이 없습니다.", color = Color.Gray, style = MaterialTheme.typography.body2)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(holdings) { stock ->
MyStockItemRow(stock) {
onSelect(stock.pdno, stock.prdt_name)
}
}
}
}
}
@Composable
fun MyStockItemRow(
stock: StockHolding,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = 1.dp,
shape = RoundedCornerShape(4.dp),
backgroundColor = Color.White
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. 종목명 및 코드
Column(modifier = Modifier.weight(1f)) {
Text(
text = stock.prdt_name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stock.pdno,
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// 2. 수익률 배지 (웹 소스 컬러 적용)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = when {
rate > 0 -> Color(0xFFE03E2D) // 매수색
rate < 0 -> Color(0xFF0E62CF) // 매도색
else -> Color.DarkGray
}
Column(horizontalAlignment = Alignment.End) {
Surface(
color = color.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rate > 0) "+" else ""}${stock.evlu_pfls_rt}%",
color = color,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
Text(
text = "${stock.prpr}",
style = MaterialTheme.typography.caption,
color = Color.DarkGray
)
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.CandleData
import model.RankingStock
import model.StockHolding
import network.KisTradeService
import network.KisWebSocketManager
import kotlin.collections.isNotEmpty
@Composable
fun OrderSection(
config: AppConfig,
token: String,
stockCode: String,
currentPrice: String,
onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달
) {
val scope = rememberCoroutineScope() // 에러 해결: scope 정의
val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의
var orderQty by remember { mutableStateOf("1") }
var orderPrice by remember { mutableStateOf("0") } // 0은 시장가
var isSubmitting by remember { mutableStateOf(false) }
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
)
}
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 = {
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)
}
}
}
}
+152
View File
@@ -0,0 +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
)
}
}
+164
View File
@@ -0,0 +1,164 @@
package ui
import androidx.compose.foundation.border
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
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.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import network.KisAuthService
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 // 파일 선택기용
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SettingsScreen(
initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요
onAuthSuccess: (AppConfig, String) -> Unit
) {
val scope = rememberCoroutineScope()
val authService = remember { KisAuthService() }
// 화면 입력 상태값
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) }
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())
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it })
Text("모의투자 서버 사용")
}
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 = "파일 선택")
}
}
// 드래그 앤 드롭 영역
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.padding(top = 8.dp)
.border(1.dp, Color.LightGray, 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
}
}),
contentAlignment = Alignment.Center
) {
Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray)
}
Spacer(modifier = 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
)
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
}
}
statusMessage = "인증 토큰 발급 시도 중..."
authService.fetchAccessToken(appKey, secretKey, isSimulation)
.onSuccess { response ->
statusMessage = "✅ 인증 성공!"
onAuthSuccess(config, response.access_token)
}.onFailure {
statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}"
}
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)
}
}
}
+182
View File
@@ -0,0 +1,182 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.CandleData
import model.RankingStock
import model.StockHolding
import network.KisTradeService
import network.KisWebSocketManager
import kotlin.collections.isNotEmpty
@Composable
fun StockDetailArea(
config: AppConfig,
token: String,
code: String,
name: String,
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
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)
}
result.onSuccess { chartData = it }
.onFailure { println("차트 로드 실패: ${it.message}") }
isLoading = false
}
LaunchedEffect(resultMessage) {
if (resultMessage.isNotEmpty()) {
delay(3000)
resultMessage = ""
}
}
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
)
}
}
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(
modifier = Modifier.fillMaxWidth().height(350.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) }
}
}
Spacer(modifier = Modifier.height(16.dp))
AiAnalysisView(
stockName = name,
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)
}
// 실시간 리스트
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tradeLogs) { trade ->
TradeLogRow(trade)
Divider(color = Color(0xFFF5F5F5))
}
}
}
OrderSection(
config = config,
token = token,
stockCode = code,
currentPrice = currentPrice,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
}
)
}
}
+63
View File
@@ -0,0 +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
)
}
}