This commit is contained in:
lunaticbum 2026-02-12 15:31:34 +09:00
parent da4ab01d5f
commit ad5e7e0ccb
8 changed files with 189 additions and 111 deletions

View File

@ -3,10 +3,10 @@ package model
import java.time.LocalDateTime import java.time.LocalDateTime
const val feesAndTaxRate = 0.33 const val feesAndTaxRate = 0.33
const val minimumNetProfit = 0.35 const val minimumNetProfit = 0.8
const val buyWeight = 2.0 const val buyWeight = 2.0
val MAX_BUDGET = 60000.0 val MAX_BUDGET = 80000.0
val MAX_PRICE = 30000 val MAX_PRICE = 40000
val MIN_PRICE = 1000 val MIN_PRICE = 1000
val MIN_PURCHASE_SCORE = 65.0 val MIN_PURCHASE_SCORE = 65.0
data class AppConfig( data class AppConfig(

View File

@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class RealTimeTrade( data class RealTimeTrade(
val code: String,
val time: String, // 체결 시간 (HH:mm:ss) val time: String, // 체결 시간 (HH:mm:ss)
val price: String, // 체결가 val price: String, // 체결가
val change: String, // 전일 대비 (대비 기호 포함) val change: String, // 전일 대비 (대비 기호 포함)

View File

@ -10,6 +10,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import model.KisSession import model.KisSession
import model.RealTimeTrade
import util.AesCrypto import util.AesCrypto
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -24,7 +25,8 @@ class KisWebSocketManager {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 콜백 리스너 // 콜백 리스너
var onPriceUpdate: ((String, Double) -> Unit)? = null var onPriceUpdate: ((RealTimeTrade) -> Unit)? = null
var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? = null var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? = null
suspend fun connect() { suspend fun connect() {
@ -70,8 +72,9 @@ class KisWebSocketManager {
} }
private val _currentPrice = mutableStateOf("0") // private val _currentPrice = mutableStateOf("0")
val currentPrice = _currentPrice private val currentPrice = androidx.compose.runtime.mutableStateMapOf<String, String>()
// val currentPrice = _currentPrice
val tradeLogs = androidx.compose.runtime.mutableStateListOf<model.RealTimeTrade>() val tradeLogs = androidx.compose.runtime.mutableStateListOf<model.RealTimeTrade>()
@ -111,20 +114,19 @@ class KisWebSocketManager {
if (trId == "H0STCNT0") { if (trId == "H0STCNT0") {
val dataRows = parts[3].split("^") val dataRows = parts[3].split("^")
val price = dataRows[2] val price = dataRows[2]
_currentPrice.value = price // 상태 업데이트 currentPrice[dataRows[0]] = price // 상태 업데이트
onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0) onPriceUpdate?.invoke(RealTimeTrade(
code = dataRows[0],
time = dataRows[1],
price = price,
change = dataRows[4],
volume = dataRows[12],
type = model.TradeType.NEUTRAL
))
// 로그 추가 (예시) // 로그 추가 (예시)
tradeLogs.add( // if (tradeLogs.isNotEmpty() && tradeLogs.last().code)
0, model.RealTimeTrade(
time = dataRows[1],
price = price,
change = dataRows[4],
volume = dataRows[12],
type = model.TradeType.NEUTRAL
)
)
if (tradeLogs.size > 50) tradeLogs.removeLast()
} }
} }
@ -133,7 +135,7 @@ class KisWebSocketManager {
// AES 복호화 실행 // AES 복호화 실행
val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv) val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv)
val dataRows = decryptedData.split("^") val dataRows = decryptedData.split("^")
println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}${dataRows[13]} 체결") println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}${if(dataRows[13] == "01"){"체결"}else{"접수"} }")
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부) // UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
onExecutionReceived?.invoke( onExecutionReceived?.invoke(
@ -150,13 +152,13 @@ class KisWebSocketManager {
fun clearData() { fun clearData() {
tradeLogs.clear() tradeLogs.clear()
_currentPrice.value = "0" currentPrice.clear()
} }
suspend fun subscribeStock(code: String, isSubscribe: Boolean = true) { suspend fun subscribeStock(code: String, isSubscribe: Boolean = true) {
val trType = if (isSubscribe) "1" else "2" val trType = if (isSubscribe) "1" else "2"
sendRequest(trType, "H0STCNT0", code) sendRequest(trType, "H0STCNT0", code)
if (isSubscribe) println("📡 구독 등록: $code") else println("📴 구독 해제: $code") // if (isSubscribe) println("📡 구독 등록: $code") else println("📴 구독 해제: $code")
} }
private suspend fun sendRequest(trType: String, trId: String, trKey: String) { private suspend fun sendRequest(trType: String, trId: String, trKey: String) {

View File

@ -42,7 +42,7 @@ object AutoTradingManager {
// 설정 상수 // 설정 상수
private const val MIN_RISE_RATE = 0.1 private const val MIN_RISE_RATE = 0.1
private const val MAX_RISE_RATE = 19.0 private const val MAX_RISE_RATE = 21.0
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분 private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
@ -50,7 +50,8 @@ object AutoTradingManager {
fun isRunning(): Boolean = discoveryJob?.isActive == true fun isRunning(): Boolean = discoveryJob?.isActive == true
private var remainingCandidates = mutableListOf<RankingStock>() private var remainingCandidates = mutableListOf<RankingStock>()
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항) // private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>()
/** /**
* 자동 발굴 루프 시작 Watchdog 실행 * 자동 발굴 루프 시작 Watchdog 실행
*/ */
@ -88,7 +89,7 @@ object AutoTradingManager {
// [프로세스 1] 장 마감 및 잔고 체크 // [프로세스 1] 장 마감 및 잔고 체크
val now = LocalTime.now(ZoneId.of("Asia/Seoul")) val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
//&& now.isBefore(LocalTime.of(15, 30)) //&& now.isBefore(LocalTime.of(15, 30))
if (now.isAfter(LocalTime.of(15, 0)) ) { if (now.isAfter(LocalTime.of(15, 30)) ) {
executeClosingLiquidation(tradeService) executeClosingLiquidation(tradeService)
return@withTimeout return@withTimeout
} }
@ -99,25 +100,31 @@ object AutoTradingManager {
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
// [프로세스 2] 후보군 수집 // [프로세스 2] 후보군 수집
if (remainingCandidates.isEmpty()) { if (remainingCandidates.isEmpty()) {
val candidates = fetchCandidates(tradeService).apply { val candidates: MutableList<RankingStock> = fetchCandidates(tradeService).apply {
println("후보군 총 개수 : $size") println("후보군 총 개수 : $size")
} }
.filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE } .filter { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) in MIN_RISE_RATE..MAX_RISE_RATE }
.filter { it.code !in myHoldings && it.code !in pendingStocks } .filter { it.code !in myHoldings && it.code !in pendingStocks }
.distinctBy { it.code } .filter { !it.name.contains("호스팩", true) }
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) } .sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
.apply { .toMutableList()
println("후보군 조건 충족 총 개수 : $size") if (reanalysisList.isNotEmpty()) {
} candidates.addAll(reanalysisList)
remainingCandidates.addAll(candidates) }
remainingCandidates.addAll(candidates.distinctBy { it.code })
} else { } else {
println("미확인 데이터 ${remainingCandidates.size}") println("미확인 데이터 ${remainingCandidates.size}")
} }
// [프로세스 3] 종목별 순회 분석
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
val stock = iterator.next()
// [프로세스 3] 종목별 순회 분석
var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
totalCount--
val stock = iterator.next()
try { try {
processSingleStock(stock, myCash, tradeService, callback) processSingleStock(stock, myCash, tradeService, callback)
// 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거 // 성공적으로 처리(또는 분석 완료) 후 리스트에서 제거
@ -128,6 +135,7 @@ object AutoTradingManager {
} finally { } finally {
iterator.remove() iterator.remove()
} }
println("남은 후보군 개수 : ${totalCount}")
delay(300) delay(300)
} }
@ -145,29 +153,40 @@ object AutoTradingManager {
} }
} }
fun addToReanalysis(stock: RankingStock) {
val count = retryCountMap.getOrDefault(stock.code, 0)
if (count < 2) { // 최대 2회까지만 재시도하여 무한 루프 방지
retryCountMap[stock.code] = count + 1
reanalysisList.add(stock)
println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
}
}
private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) {
try { try {
// 개별 종목 분석은 최대 2분으로 제한 // 개별 종목 분석은 최대 2분으로 제한
withTimeout(120000L) { withTimeout(120000L) {
val corpInfo = DartCodeManager.getCorpCode(stock.code) val corpInfo = DartCodeManager.getCorpCode(stock.code)
if (corpInfo?.cName.isNullOrEmpty()) { if (corpInfo?.cName.isNullOrEmpty()) {
// println("⏭️ [제외] ${stock.name}: 법인명 정보를 찾을 수 없음")
return@withTimeout return@withTimeout
} }
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
val today = dailyData.lastOrNull() ?: return@withTimeout
val currentPrice = today.stck_prpr.toDouble()
if (currentPrice > myCash || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) return@withTimeout
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
callback(TradingDecision().apply { callback(TradingDecision().apply {
this.stockCode = stock.code this.stockCode = stock.code
this.confidence = -1.0 this.confidence = -1.0
this.stockName = stock.name this.stockName = stock.name
}, false) }, false)
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
val today = dailyData.lastOrNull() ?: return@withTimeout
val currentPrice = today.stck_prpr.toDouble()
if (currentPrice > myCash || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) {
return@withTimeout
}
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
val analyzer = coroutineScope { val analyzer = coroutineScope {
val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }

View File

@ -282,7 +282,6 @@ object SafeScraper {
delay(Random.nextLong(500, 1500)) delay(Random.nextLong(500, 1500))
} }
} }
println("🏁 뉴스 처리 완료")
} }
} }

View File

@ -246,7 +246,7 @@ fun DashboardScreen() {
}, },
stockCode = selectedStockCode, stockCode = selectedStockCode,
stockName = selectedStockName, stockName = selectedStockName,
currentPrice = wsManager.currentPrice.value, currentPrice = "0",
trades = wsManager.tradeLogs, trades = wsManager.tradeLogs,
tradingDecisionCallback = { decision,bool -> tradingDecisionCallback = { decision,bool ->
if (bool && decision != null && KisSession.config.isSimulation) { if (bool && decision != null && KisSession.config.isSimulation) {

View File

@ -22,10 +22,12 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.MAX_BUDGET import model.MAX_BUDGET
import model.MIN_PURCHASE_SCORE import model.MIN_PURCHASE_SCORE
import model.RankingStock
import model.buyWeight import model.buyWeight
import model.feesAndTaxRate import model.feesAndTaxRate
import model.minimumNetProfit import model.minimumNetProfit
import network.KisTradeService import network.KisTradeService
import service.AutoTradingManager
import util.MarketUtil import util.MarketUtil
enum class InvestmentGrade( enum class InvestmentGrade(
@ -50,7 +52,7 @@ enum class InvestmentGrade(
shortWeight = 0.8, shortWeight = 0.8,
midWeight = 1.0, midWeight = 1.0,
longWeight = 1.0, longWeight = 1.0,
profitGuide = 1.4, profitGuide = 1.3,
), ),
LEVEL_3_CAUTIOUS_RECOMMEND( LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천", displayName = "보수적 추천",
@ -58,7 +60,7 @@ enum class InvestmentGrade(
shortWeight = 0.6, shortWeight = 0.6,
midWeight = 1.0, midWeight = 1.0,
longWeight = 1.0, longWeight = 1.0,
profitGuide = 1.0, profitGuide = 0.9,
), ),
LEVEL_2_HIGH_RISK( LEVEL_2_HIGH_RISK(
displayName = "고위험 추천", displayName = "고위험 추천",
@ -66,7 +68,7 @@ enum class InvestmentGrade(
shortWeight = 1.0, shortWeight = 1.0,
midWeight = 0.4, midWeight = 0.4,
longWeight = 0.4, longWeight = 0.4,
profitGuide = 0.8, profitGuide = 0.7,
), ),
LEVEL_1_SPECULATIVE( LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택", displayName = "순수 공격적 선택",
@ -74,7 +76,7 @@ enum class InvestmentGrade(
shortWeight = 1.0, shortWeight = 1.0,
midWeight = 0.2, midWeight = 0.2,
longWeight = 0.2, longWeight = 0.2,
profitGuide = 0.6, profitGuide = 0.5,
) )
} }
@ -131,10 +133,14 @@ fun IntegratedOrderSection(
var stopLossRate by remember(monitoringItem) { var stopLossRate by remember(monitoringItem) {
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5") mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5")
} }
var basePrice: Double = 0.0
LaunchedEffect(currentPrice) {
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
basePrice = curPriceNum
}
// 계산용 변수 // 계산용 변수
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
fun getInvestmentGrade( fun getInvestmentGrade(
ts: TradingDecision, ts: TradingDecision,
@ -187,9 +193,9 @@ fun IntegratedOrderSection(
val tickSize = MarketUtil.getTickSize(basePrice) val tickSize = MarketUtil.getTickSize(basePrice)
val oneTickLowerPrice = basePrice - (tickSize * when(investmentGrade) { val oneTickLowerPrice = basePrice - (tickSize * when(investmentGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> 1 InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> 1
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> 2 InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> 1
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> 3 InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> 2
InvestmentGrade.LEVEL_2_HIGH_RISK -> 3 InvestmentGrade.LEVEL_2_HIGH_RISK -> 2
InvestmentGrade.LEVEL_1_SPECULATIVE -> 4 InvestmentGrade.LEVEL_1_SPECULATIVE -> 4
else -> 4 else -> 4
}) })
@ -249,10 +255,8 @@ fun IntegratedOrderSection(
var append = 0.0 var append = 0.0
if (completeTradingDecision != null && if (completeTradingDecision != null &&
completeTradingDecision.stockCode.equals(stockCode)) { completeTradingDecision.stockCode.equals(stockCode)) {
println("basePrice $basePrice")
fun resultCheck(completeTradingDecision :TradingDecision) { fun resultCheck(completeTradingDecision :TradingDecision) {
val weights = mapOf( val weights = mapOf(
"short" to 0.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 "short" to 0.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
"profit" to 0.3, "profit" to 0.3,
@ -263,23 +267,25 @@ fun IntegratedOrderSection(
(completeTradingDecision.shortPossible() * weights["short"]!!) + (completeTradingDecision.shortPossible() * weights["short"]!!) +
(completeTradingDecision.profitPossible() * weights["profit"]!!) + (completeTradingDecision.profitPossible() * weights["profit"]!!) +
(completeTradingDecision.safePossible() * weights["safe"]!!) (completeTradingDecision.safePossible() * weights["safe"]!!)
println("""
corpName : ${completeTradingDecision.corpName}
confidence : ${completeTradingDecision.confidence + append}
shortPossible : ${completeTradingDecision.shortPossible() + append}
profitPossible : ${completeTradingDecision.profitPossible()+ append}
safePossible : ${completeTradingDecision.safePossible()+ append}
totalScore : ${totalScore}
""".trimIndent())
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) { if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
// 4. 점수에 따른 가변 마진 적용 // 4. 점수에 따른 가변 마진 적용
// 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용 // 토탈 스코어가 85점 이상이면 마진을 3.0으로 고정하거나 추가 가산(append) 적용
val finalMargin = minimumNetProfit * investmentGrade.profitGuide val finalMargin = minimumNetProfit * investmentGrade.profitGuide
println("""
사명 : ${completeTradingDecision.corpName}
신뢰도 : ${completeTradingDecision.confidence + append}
단기성 : ${completeTradingDecision.shortPossible() + append}
수익성 : ${completeTradingDecision.profitPossible()+ append}
안전성 : ${completeTradingDecision.safePossible()+ append}
${investmentGrade.displayName} : ${investmentGrade.description}
총점 : ${totalScore}
""".trimIndent())
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val calculatedQty = if (basePrice > 0) { val calculatedQty = if (basePrice > 0) {
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1) (MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
} else { } else {
@ -293,8 +299,11 @@ fun IntegratedOrderSection(
investmentGrade = investmentGrade, investmentGrade = investmentGrade,
) )
} else { } else if(totalScore >= (MIN_PURCHASE_SCORE * 0.85) && completeTradingDecision.confidence >= (MIN_CONFIDENCE * 0.85)) {
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)})가 기준치($MIN_PURCHASE_SCORE) 미달 또는 신뢰도 ${completeTradingDecision.confidence}가 기준치 ${MIN_CONFIDENCE} 미달") AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
} else {
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달")
} }
} }
when (completeTradingDecision?.decision) { when (completeTradingDecision?.decision) {

View File

@ -21,8 +21,6 @@ import model.CandleData
import network.DartCodeManager import network.DartCodeManager
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import network.NewsService
import service.TechnicalAnalyzer
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
@ -44,7 +42,7 @@ fun StockDetailSection(
yearSummary : MutableList<CandleData> yearSummary : MutableList<CandleData>
) { ) {
var openPrice by remember { mutableStateOf("0") } // var openPrice by remember { mutableStateOf("0") }
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) } var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var resultMessage by remember { mutableStateOf("") } var resultMessage by remember { mutableStateOf("") }
@ -62,6 +60,8 @@ fun StockDetailSection(
// 이전 종목 코드를 기억하기 위한 상태 // 이전 종목 코드를 기억하기 위한 상태
var previousCode by remember { mutableStateOf("") } var previousCode by remember { mutableStateOf("") }
var latestPrice by remember { mutableStateOf("0") }
// 종목 변경 시 데이터 로드 및 웹소켓 구독 관리 // 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
LaunchedEffect(stockCode) { LaunchedEffect(stockCode) {
@ -81,16 +81,62 @@ fun StockDetailSection(
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
coroutineScope { coroutineScope {
launch {
wsManager.onPriceUpdate = {tradeLog ->
if (tradeLog.code.equals(stockCode)) {
val code = tradeLog.code
val price = tradeLog.price
wsManager.tradeLogs.add(tradeLog)
if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast()
println("code $code ,price $price")
val currentPrice = price
if (chartData.isNotEmpty() && currentPrice != "0") {
val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
val lastCandle = chartData.last()
// 현재 시간(분 단위) 확인
val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
if (lastCandle.stck_bsop_date != currentMinute) {
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
val newCandle = CandleData(
stck_bsop_date = currentMinute,
stck_oprc = currentPrice,
stck_hgpr = currentPrice,
stck_lwpr = currentPrice,
stck_prpr = currentPrice,
stck_cntg_hour = currentMinute,
cntg_vol = "1",
acml_tr_pbmn = "1",
)
// 최대 100개까지만 유지하여 성능 최적화
chartData = (chartData + newCandle).takeLast(100)
} else {
// 같은 분 내에서는 기존 마지막 캔들만 업데이트
val updatedCandle = lastCandle.copy(
stck_prpr = currentPrice,
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr,
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr
)
chartData = chartData.dropLast(1) + updatedCandle
}
}
latestPrice = currentPrice
}
}
}
launch {tradeService.fetchChartData(stockCode, isDomestic) launch {tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data -> .onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력 // println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
chartData = data chartData = data
min30.clear() min30.clear()
min30.addAll(chartData) min30.addAll(chartData)
} }
.onFailure { error -> .onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") // println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList() chartData = emptyList()
} }
} }
@ -122,41 +168,43 @@ fun StockDetailSection(
isLoading = false isLoading = false
} }
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
LaunchedEffect(latestPrice) {
if (chartData.isNotEmpty() && latestPrice != "0") {
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
val lastCandle = chartData.last()
// 현재 시간(분 단위) 확인 // LaunchedEffect(latestPrice) {
val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00")) // println("latestPrice >>> $latestPrice")
// if (chartData.isNotEmpty() && latestPrice != "0") {
if (lastCandle.stck_bsop_date != currentMinute) { // val latestPrice = latestPrice ?: "0"
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) // val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect
val newCandle = CandleData( // val lastCandle = chartData.last()
stck_bsop_date = currentMinute, //
stck_oprc = latestPrice, // // 현재 시간(분 단위) 확인
stck_hgpr = latestPrice, // val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
stck_lwpr = latestPrice, //
stck_prpr = latestPrice, // if (lastCandle.stck_bsop_date != currentMinute) {
stck_cntg_hour = currentMinute, // // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
cntg_vol = "1", // val newCandle = CandleData(
acml_tr_pbmn = "1", // stck_bsop_date = currentMinute,
) // stck_oprc = latestPrice,
// 최대 100개까지만 유지하여 성능 최적화 // stck_hgpr = latestPrice,
chartData = (chartData + newCandle).takeLast(100) // stck_lwpr = latestPrice,
} else { // stck_prpr = latestPrice,
// 같은 분 내에서는 기존 마지막 캔들만 업데이트 // stck_cntg_hour = currentMinute,
val updatedCandle = lastCandle.copy( // cntg_vol = "1",
stck_prpr = latestPrice, // acml_tr_pbmn = "1",
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 // // 최대 100개까지만 유지하여 성능 최적화
) // chartData = (chartData + newCandle).takeLast(100)
chartData = chartData.dropLast(1) + updatedCandle // } else {
} // // 같은 분 내에서는 기존 마지막 캔들만 업데이트
} // val updatedCandle = lastCandle.copy(
} // stck_prpr = latestPrice,
// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
// )
// chartData = chartData.dropLast(1) + updatedCandle
// }
// }
// }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// [상단] 종목명 및 상태 메시지 // [상단] 종목명 및 상태 메시지
@ -170,7 +218,7 @@ fun StockDetailSection(
code = stockCode, code = stockCode,
isDomestic = isDomestic, isDomestic = isDomestic,
previousClose = previousClose, previousClose = previousClose,
openPrice = openPrice, openPrice = latestPrice,
resultMessage = resultMessage, resultMessage = resultMessage,
resultMessageClear = {resultMessage = ""}, resultMessageClear = {resultMessage = ""},
isSuccess = isSuccess isSuccess = isSuccess
@ -179,10 +227,10 @@ fun StockDetailSection(
// 실시간 가격 표시 (WebSocket 데이터) // 실시간 가격 표시 (WebSocket 데이터)
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text( Text(
text = "${wsManager.currentPrice.value}", text = "${latestPrice}",
style = MaterialTheme.typography.h4, style = MaterialTheme.typography.h4,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (wsManager.currentPrice.value.contains("-")) Color.Blue else Color.Red color = if (latestPrice?.contains("-") ?: false) Color.Blue else Color.Red
) )
Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray) Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray)
} }
@ -228,7 +276,7 @@ fun StockDetailSection(
stockCode = stockCode, stockCode = stockCode,
stockName = stockName, stockName = stockName,
isDomestic = isDomestic, isDomestic = isDomestic,
currentPrice = wsManager.currentPrice.value, currentPrice = latestPrice,
holdingQuantity = holdingQuantity, holdingQuantity = holdingQuantity,
tradeService = tradeService, tradeService = tradeService,
onOrderSaved = onOrderSaved, onOrderSaved = onOrderSaved,