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
+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
}
)
}
}