From bdc268e325e3ed906bbd3f5fe4313608d797e130 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 14 Jan 2026 15:42:26 +0900 Subject: [PATCH] .... --- build.gradle.kts | 1 + src/main/kotlin/Main.kt | 1 + src/main/kotlin/database/DatabaseFactory.kt | 144 +++++++++++--- src/main/kotlin/model/AppConfig.kt | 2 + src/main/kotlin/model/StockModels.kt | 39 +++- src/main/kotlin/network/KisTradeService.kt | 172 +++++++++++++++-- .../kotlin/network/KisWebSocketManager.kt | 178 +++++++++++------- src/main/kotlin/ui/ActiveTradeRow.kt | 104 ++++++++++ src/main/kotlin/ui/AutoTradeSection.kt | 100 ++++++++++ src/main/kotlin/ui/AutoTradeSettingCard.kt | 80 ++++++++ src/main/kotlin/ui/CandleChart.kt | 20 +- src/main/kotlin/ui/DashboardScreen.kt | 79 +++++++- src/main/kotlin/ui/IntegratedOrderSection.kt | 142 ++++++++++++++ src/main/kotlin/ui/PeriodTrendCard.kt | 57 ++++++ src/main/kotlin/ui/SettingsScreen.kt | 8 +- src/main/kotlin/ui/StockDetailArea.kt | 145 +++++++++++--- src/main/kotlin/ui/StockHeader.kt | 73 +++---- src/main/kotlin/ui/SummaryGraphCard.kt | 53 ++++++ 18 files changed, 1193 insertions(+), 205 deletions(-) create mode 100644 src/main/kotlin/ui/ActiveTradeRow.kt create mode 100644 src/main/kotlin/ui/AutoTradeSection.kt create mode 100644 src/main/kotlin/ui/AutoTradeSettingCard.kt create mode 100644 src/main/kotlin/ui/IntegratedOrderSection.kt create mode 100644 src/main/kotlin/ui/PeriodTrendCard.kt create mode 100644 src/main/kotlin/ui/SummaryGraphCard.kt diff --git a/build.gradle.kts b/build.gradle.kts index 77ce513..1bd96e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") } compose.desktop { diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index b72acc1..c24bc5d 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -39,6 +39,7 @@ fun main() = application { vtsSecretKey = it[ConfigTable.vtsSecretKey], vtsAccountNo = it[ConfigTable.vtsAccountNo], isSimulation = it[ConfigTable.isSimulation], + htsId = it[ConfigTable.htsId], modelPath = it[ConfigTable.modelPath] ) } diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 7f91489..51efca9 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -1,5 +1,6 @@ import model.AppConfig import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.transactions.transaction import java.io.File @@ -16,37 +17,110 @@ object ConfigTable : Table("app_config") { val vtsAccountNo = varchar("vts_account_no", 20).default("") val isSimulation = bool("is_simulation").default(true) val modelPath = varchar("model_path", 512).default("") + val htsId = varchar("hts_id", 50).default("") // HTS ID 컬럼 추가 override val primaryKey = PrimaryKey(id) } -// 2. 거래 내역 테이블 (대량 데이터용) +// 2. 자동매매 감시 테이블 +object AutoTradeTable : Table("auto_trades") { + val id = integer("id").autoIncrement() + val stockCode = varchar("stock_code", 20) + val stockName = varchar("stock_name", 100) + val targetPrice = double("target_price") // 익절 목표가 + val stopLossPrice = double("stop_loss_price") // 손절 목표가 + val status = varchar("status", 20).default("MONITORING") // MONITORING, COMPLETED + val isDomestic = bool("is_domestic").default(true) + override val primaryKey = PrimaryKey(id) +} + +// 3. 거래 내역 테이블 object TradeLogTable : Table("trade_logs") { val id = long("id").autoIncrement() - val stockCode = varchar("stock_code", 20) // 종목코드 - val stockName = varchar("stock_name", 50) // 종목명 - val tradeType = varchar("trade_type", 10) // 매수/매도 - val price = double("price") // 체결가 - val quantity = integer("quantity") // 수량 - val timestamp = datetime("timestamp") // 거래 시간 - val logMessage = text("log_message") // Ollama의 판단 근거 등 상세 정보 + val stockCode = varchar("stock_code", 20) + val stockName = varchar("stock_name", 50) + val tradeType = varchar("trade_type", 10) + val price = double("price") + val quantity = integer("quantity") + val timestamp = datetime("timestamp") + val logMessage = text("log_message") override val primaryKey = PrimaryKey(id) } object DatabaseFactory { fun init() { - - val dbPath =File("db/autotrade_db").absolutePath - // 드라이버를 org.h2.Driver로 설정 + val dbPath = File("db/autotrade_db").absolutePath Database.connect( "jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver" ) transaction { - SchemaUtils.create(ConfigTable, TradeLogTable) + // 테이블 생성 (AutoTradeTable 포함) + SchemaUtils.create(ConfigTable, TradeLogTable, AutoTradeTable) } } + // --- 자동매매(감시) 관련 함수 --- + + /** + * [추가] 종목코드로 현재 감시 중인 설정 가져오기 (웹소켓 감시용) + */ + fun findConfigByCode(code: String): AutoTradeItem? = transaction { + AutoTradeTable.select { + (AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING") + }.lastOrNull()?.let { + mapToAutoTradeItem(it) + } + } + + /** + * [추가] 매수 체결 시 새로운 자동매매 감시 대상 등록 + */ + fun saveAutoTrade(item: AutoTradeItem) { + transaction { + // 동일 종목이 이미 감시 중이면 삭제 후 재등록 (중복 방지) + AutoTradeTable.deleteWhere { stockCode eq item.code } + + AutoTradeTable.insert { + it[stockCode] = item.code + it[stockName] = item.name + it[targetPrice] = item.targetPrice + it[stopLossPrice] = item.stopLossPrice + it[status] = "MONITORING" + it[isDomestic] = item.isDomestic + } + } + } + + /** + * [추가] 매도 완료 또는 취소 시 감시 대상 삭제 + */ + fun deleteAutoTrade(code: String) { + transaction { + AutoTradeTable.deleteWhere { stockCode eq code } + } + } + + /** + * [수정] 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용) + */ + fun getActiveAutoTrades(): List = transaction { + AutoTradeTable.select { AutoTradeTable.status eq "MONITORING" } + .map { mapToAutoTradeItem(it) } + } + + // ResultRow를 AutoTradeItem으로 매핑하는 내부 함수 + private fun mapToAutoTradeItem(it: ResultRow) = AutoTradeItem( + code = it[AutoTradeTable.stockCode], + name = it[AutoTradeTable.stockName], + targetPrice = it[AutoTradeTable.targetPrice], + stopLossPrice = it[AutoTradeTable.stopLossPrice], + status = it[AutoTradeTable.status], + isDomestic = it[AutoTradeTable.isDomestic] + ) + + // --- 기존 설정 및 로그 관련 함수 --- + fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) { transaction { TradeLogTable.insert { @@ -61,28 +135,26 @@ object DatabaseFactory { } } - fun findConfigByAccount(accountNo: String): AppConfig? { - return transaction { - ConfigTable.select { - (ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo) - }.lastOrNull()?.let { - AppConfig( - realAppKey = it[ConfigTable.realAppKey], - realSecretKey = it[ConfigTable.realSecretKey], - realAccountNo = it[ConfigTable.realAccountNo], - vtsAppKey = it[ConfigTable.vtsAppKey], - vtsSecretKey = it[ConfigTable.vtsSecretKey], - vtsAccountNo = it[ConfigTable.vtsAccountNo], - isSimulation = it[ConfigTable.isSimulation], - modelPath = it[ConfigTable.modelPath] - ) - } + fun findConfigByAccount(accountNo: String): AppConfig? = transaction { + ConfigTable.select { + (ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo) + }.lastOrNull()?.let { + AppConfig( + realAppKey = it[ConfigTable.realAppKey], + realSecretKey = it[ConfigTable.realSecretKey], + realAccountNo = it[ConfigTable.realAccountNo], + vtsAppKey = it[ConfigTable.vtsAppKey], + vtsSecretKey = it[ConfigTable.vtsSecretKey], + vtsAccountNo = it[ConfigTable.vtsAccountNo], + isSimulation = it[ConfigTable.isSimulation], + htsId = it[ConfigTable.htsId], // htsId 로드 + modelPath = it[ConfigTable.modelPath] + ) } } fun saveConfig(config: AppConfig) { transaction { - // 기존 설정을 모두 지우고 최신 설정 하나만 유지 ConfigTable.deleteAll() ConfigTable.insert { it[realAppKey] = config.realAppKey @@ -92,9 +164,21 @@ object DatabaseFactory { it[realAccountNo] = config.realAccountNo it[vtsAccountNo] = config.vtsAccountNo it[isSimulation] = config.isSimulation + it[htsId] = config.htsId it[modelPath] = config.modelPath } } } +} -} \ No newline at end of file +/** + * [수정] 감시 가격(익절/손절) 정보를 포함하도록 모델 확장 + */ +data class AutoTradeItem( + val code: String, + val name: String, + val targetPrice: Double, + val stopLossPrice: Double, // 손절가 추가 + val status: String, + val isDomestic: Boolean +) \ No newline at end of file diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index b2edb25..5866b24 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -21,6 +21,8 @@ data class AppConfig( var tradeToken: String = "", var tradeTokenExpiredAt: LocalDateTime? = null, + val htsId: String = "", + var websocketToken: String = "", val isSimulation: Boolean = true, val modelPath: String = "") { diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index cd2abc5..cd7bdf4 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -65,14 +65,15 @@ data class RankingStock( val hts_kor_alph_nm: String = "", // 종목명 val mkrtc_objt_iscd: String = "", // 종목코드 val mksc_shrn_iscd: String = "", // 종목코드 + val stck_shrn_iscd: String = "", // 종목코드 val stck_prpr: String = "0", // 현재가 val prdy_ctrt: String = "0.0", // 등락률 val mrkt_div_cls_code : String = "J", ) { val name : String - get() = hts_kor_isnm ?: hts_kor_alph_nm ?: mkrtc_objt_iscd ?: "" + get() = listOf(hts_kor_isnm , hts_kor_alph_nm , mkrtc_objt_iscd).firstOrNull { it.isNotBlank() } ?: "" val code : String - get() = mksc_shrn_iscd ?: mkrtc_objt_iscd ?: hts_kor_isnm ?: "" + get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd , hts_kor_isnm).firstOrNull { it.isNotBlank() } ?: "" } @Serializable data class OverseasRankingResponse( @@ -116,3 +117,37 @@ data class UnifiedBalance( val totalProfitRate: String, // 총 수익률 val holdings: List // 통합 보유 종목 리스트 ) + + +@Serializable +data class UnfilledOrder( + val ord_no: String, // 주문번호 + val orgn_ord_no: String, // 원주문번호 + val pdno: String, // 종목코드 + val prdt_name: String, // 종목명 + val ord_qty: String, // 주문수량 + val ord_unpr: String, // 주문단가 + val rmnd_qty: String, // 체결 잔량 (미체결 수량) + val ord_tmd: String, // 주문시각 + val sll_buy_dvsn_cd: String // 매도매수구분 (01: 매도, 02: 매수) +) + +@Serializable +data class UnfilledResponse( + val rt_cd: String, + val msg1: String, + val output: List = emptyList() +) + +// src/main/kotlin/model/TradeModels.kt 내 추가 +enum class ActiveTradeType { MONITORING, UNFILLED } + +data class ActiveTradeItem( + val id: String, // DB ID 또는 주문번호 + val code: String, + val name: String, + val type: ActiveTradeType, + val price: Double, // 목표가 또는 주문단가 + val quantity: String, // 미체결 수량 (감시 중에는 "-") + val isDomestic: Boolean +) \ No newline at end of file diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 92cbbb2..674b82d 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -25,6 +25,9 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import model.* +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter class KisTradeService { private val client = HttpClient(CIO) { @@ -173,6 +176,66 @@ class KisTradeService { } catch (e: Exception) { Result.failure(e) } } + /** + * [추가] 기간별(일/주/월) 차트 데이터 조회 + * @param periodCode "D"(일), "W"(주), "M"(월) + */ + suspend fun fetchPeriodChartData( + stockCode: String, + periodCode: String = "D", + isDomestic: Boolean = true + ): Result> { + val config = KisSession.config + val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice" + + val today = LocalDate.now() + val formatter = DateTimeFormatter.ofPattern("yyyyMMdd") + val endDate = today.format(formatter) + + // [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전) + // 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다. + val startDate = when (periodCode) { + "D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분 + "W" -> today.minusYears(2).format(formatter) // 주봉: 2년치 + "M" -> today.minusYears(8).format(formatter) // 월봉: 8년치 + else -> today.minusYears(1).format(formatter) + } + return try { + val response = client.get("$prodUrl$path") { + header("authorization", "Bearer ${config.marketToken}") + header("appkey", config.realAppKey) + header("appsecret", config.realSecretKey) + header("tr_id", if (isDomestic) "FHKST03010100" else "HHDFS76240000") + header("custtype", "P") + + parameter("FID_INPUT_DATE_1", startDate) + parameter("FID_INPUT_DATE_2", endDate) + parameter("FID_COND_MRKT_DIV_CODE", "J") + parameter("FID_INPUT_ISCD", stockCode) + parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M + parameter("FID_ORG_ADJ_PRC", "0") + } + + val body = response.body() + val output2 = body["output2"]?.jsonArray + + val candles = output2?.map { element -> + val obj = element.jsonObject + CandleData( + stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "", + stck_clpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0", + stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0", + stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0", + stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0", + acml_vol = obj["acml_vol"]?.jsonPrimitive?.content ?: "0" + ) + }?.reversed() ?: emptyList() + + Result.success(candles) + } catch (e: Exception) { Result.failure(e) } + } + /** * [해외 주식 순위] 모델 매핑 오류 수정 */ @@ -217,22 +280,98 @@ class KisTradeService { suspend fun postOrder( stockCode: String, qty: String, - price: String, // "0" 이면 시장가 + price: String, isBuy: Boolean ): Result { val config = KisSession.config val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() } val baseUrl = if (config.isSimulation) vtsUrl else prodUrl + // 계좌번호 처리: 8자리면 01 자동 추가 + var pureAccount = config.accountNo.replace("-", "").trim() + if (pureAccount.length == 8) pureAccount += "01" + + val cano = pureAccount.take(8) + val acntPrdtCd = pureAccount.takeLast(2) + val trId = when { - isDomestic && config.isSimulation -> if (isBuy) "VTRP0001U" else "VTRP0002U" + isDomestic && config.isSimulation -> if (isBuy) "VTTC0802U" else "VTTC0801U" isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U" - !isDomestic && config.isSimulation -> if (isBuy) "VTTT3001U" else "VTTT3002U" - else -> if (isBuy) "TTTS3001U" else "TTTS3002U" + else -> if (isBuy) "TTTS3002U" else "TTTS3001U" } return try { val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") { + header("authorization", "Bearer ${config.tradeToken}") + header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) + header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) + header("tr_id", trId) + header("custtype", "P") // [해결] 필수 헤더 추가 + header("Content-Type", "application/json") + + setBody(mapOf( + "CANO" to cano, + "ACNT_PRDT_CD" to acntPrdtCd, + "PDNO" to stockCode, + "ORD_DVSN" to if (price == "0" || price.isEmpty()) "01" else "00", + "ORD_QTY" to qty, + "ORD_UNPR" to if (price.isEmpty() || price == "0") "0" else price + )) + } + + val body = response.body() // [해결] Polymorphic 직렬화 에러 방지 + val rtCd = body["rt_cd"]?.jsonPrimitive?.content + val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음" + + if (rtCd == "0") Result.success("✅ 주문 성공: $msg") + else Result.failure(Exception("❌ 오류 ($rtCd): $msg")) + } catch (e: Exception) { Result.failure(e) } + } + + + /** + * [추가] 국내 미체결 내역 조회 + */ + suspend fun fetchUnfilledOrders(): Result> { + val config = KisSession.config + if (config.isSimulation) Result.success(emptyList()) + val baseUrl = if (config.isSimulation) vtsUrl else prodUrl + val trId = "TTTC0084R" + + return try { + val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl") { + header("authorization", "Bearer ${config.tradeToken}") + header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) + header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) + header("tr_id", trId) + header("custtype", "P") + + parameter("CANO", config.accountNo.take(8)) + parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2)) + parameter("CTX_AREA_FK100", "") + parameter("CTX_AREA_NK100", "") + parameter("T_GUBUN", "0") + parameter("LOAN_DT", "") + parameter("P_S_GUBUN", "0") + parameter("INQR_DVSN_1", "0") + parameter("INQR_DVSN_2", "0") + } + val body = response.body() + if (body.rt_cd == "0") Result.success(body.output) + else Result.failure(Exception(body.msg1)) + } catch (e: Exception) { Result.failure(e) } + } + + /** + * [추가] 주문 취소 (정정/취소 API) + */ + suspend fun cancelOrder(orgNo: String, stockCode: String): Result { + val config = KisSession.config + val baseUrl = if (config.isSimulation) vtsUrl else prodUrl + val trId = if (config.isSimulation) "VTTC0803U" else "TTTC0803U" + + return try { + val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-rvsecncl") { header("authorization", "Bearer ${config.tradeToken}") header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) @@ -242,15 +381,17 @@ class KisTradeService { setBody(mapOf( "CANO" to config.accountNo.take(8), "ACNT_PRDT_CD" to config.accountNo.takeLast(2), - "PDNO" to stockCode, - "ORD_DVSN" to if (price == "0") "01" else "00", - "ORD_QTY" to qty, - "ORD_UNPR" to price + "KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호 + "ORGN_ORD_NO" to orgNo, // 취소할 원주문번호 + "RVSE_CNCL_DVSN" to "02", // 01: 정정, 02: 취소 + "ORD_DVSN" to "00", // 지정가 + "ORD_QTY" to "0", // 0이면 전량 취소 + "ORD_UNPR" to "0" )) } - val body = response.body>() - if (body["rt_cd"] == "0") Result.success("✅ 주문 성공: ${body["msg1"]}") - else Result.failure(Exception("${body["msg1"]}")) + val body = response.body() + if (body["rt_cd"]?.jsonPrimitive?.content == "0") Result.success("취소 완료") + else Result.failure(Exception(body["msg1"]?.jsonPrimitive?.content)) } catch (e: Exception) { Result.failure(e) } } @@ -284,7 +425,12 @@ class KisTradeService { val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice" - + val now = LocalTime.now() + val searchTime = if (now.isAfter(LocalTime.of(15, 30))) { + "153000" + } else { + now.format(DateTimeFormatter.ofPattern("HHmmss")) + } return try { val response = client.get("$prodUrl$path") { header("authorization", "Bearer ${config.marketToken}") @@ -297,7 +443,7 @@ class KisTradeService { parameter("FID_ETC_CLS_CODE", "") parameter("FID_COND_MRKT_DIV_CODE", "J") parameter("FID_INPUT_ISCD", stockCode) - parameter("FID_INPUT_HOUR_1", "153000") // 장 마감 시간까지 + parameter("FID_INPUT_HOUR_1", searchTime) // 장 마감 시간까지 parameter("FID_PW_DATA_INCU_YN", "Y") // 전일 데이터 포함 여부 } diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt index aaec7e7..3136210 100644 --- a/src/main/kotlin/network/KisWebSocketManager.kt +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -6,61 +6,49 @@ import androidx.compose.ui.graphics.Color import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.logging.DEFAULT -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.websocket.* import io.ktor.http.* import io.ktor.websocket.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.consumeAsFlow -import model.AppConfig import model.KisSession import model.RealTimeTrade import model.TradeType class KisWebSocketManager { private val client = HttpClient(CIO) { - install(WebSockets) { - pingInterval = 20_000 - } + install(WebSockets) { pingInterval = 20_000 } install(HttpTimeout) { requestTimeoutMillis = 15_000 connectTimeoutMillis = 15_000 - socketTimeoutMillis = 15_000 - } - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경 } } private var session: DefaultClientWebSocketSession? = null private val scope = CoroutineScope(Dispatchers.Default + Job()) - // UI 관찰 상태값 + // UI 상태값 val currentPrice = mutableStateOf("0") - val priceChangeColor = mutableStateOf(Color.Transparent) val tradeLogs = mutableStateListOf() + + // 콜백: 체결 발생 시 (주문번호, 종목코드, 가격, 수량, 매수/매도여부) + var onExecutionReceived: ((orderNo: String, code: String, price: String, qty: String, isBuy: Boolean) -> Unit)? = null + + // 콜백: 감시 조건 도달 시 (종목코드, 현재가, 타입) + var onTargetReached: ((code: String, price: Double, isProfit: Boolean) -> Unit)? = null + suspend fun connect() { val config = KisSession.config - val approvalKey = config.websocketToken + if (config.websocketToken.isEmpty()) return - if (approvalKey.isEmpty()) { - println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.") - return - } - - // 시세 데이터는 항상 실전 서버(21000)를 권장합니다. val hostUrl = "ops.koreainvestment.com" - val port = 21000 + val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름) scope.launch { try { client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") { session = this - println("✅ 웹소켓 연결 성공") + println("✅ 웹소켓 서버 연결 성공") incoming.consumeAsFlow().collect { frame -> if (frame is Frame.Text) { @@ -75,66 +63,107 @@ class KisWebSocketManager { } private fun parseTradeData(data: String) { - // 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터 + // KIS 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터 val parts = data.split("|") - if (parts.size > 3) { - val rows = parts[3].split("^") - if (rows.size > 15) { - val newTrade = RealTimeTrade( - time = rows[1].chunked(2).joinToString(":"), // HHMMSS -> HH:MM:SS - price = rows[2], - change = rows[4], - volume = rows[12], - type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL - ) + if (parts.size < 4) return - // 메인 스레드에서 UI 상태 업데이트 - CoroutineScope(Dispatchers.Main).launch { - tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로 - if (tradeLogs.size > 30) tradeLogs.removeLast() + val trId = parts[1] + val body = parts[3] - // 현재가 및 색상 업데이트 로직 포함 가능 - currentPrice.value = newTrade.price - } + when (trId) { + "H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리 + "H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리 + } + } + + /** + * [1] 실시간 가격 데이터 처리 및 감시 로직 + */ + private fun handlePriceData(body: String) { + val rows = body.split("^") + if (rows.size < 16) return + + val stockCode = rows[0] + val priceStr = rows[2] + val currentPriceInt = priceStr.toIntOrNull() ?: 0 + + val newTrade = RealTimeTrade( + time = rows[1].chunked(2).joinToString(":"), + price = priceStr, + change = rows[4], + volume = rows[12], + type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL + ) + + scope.launch(Dispatchers.Main) { + tradeLogs.add(0, newTrade) + if (tradeLogs.size > 30) tradeLogs.removeLast() + currentPrice.value = String.format("%,d", currentPriceInt) + + // 실시간 감시 엔진 작동 + checkAutoTradeTargets(stockCode, currentPriceInt.toDouble()) + } + } + + /** + * [2] 실시간 개인 체결 통보 처리 + */ + private fun handleExecutionData(body: String) { + val rows = body.split("^") + if (rows.size < 13) return + + val orderNo = rows[1] + val stockCode = rows[7] + val side = rows[9] // 01: 매도, 02: 매수 + val price = rows[11] + val qty = rows[12] + + scope.launch(Dispatchers.Main) { + val isBuy = side == "02" + println("📣 체결 통보 수신: $stockCode | ${if(isBuy) "매수" else "매도"} | $price 원") + + // 외부 콜백 실행 (DB 업데이트 및 UI 전환 트리거) + onExecutionReceived?.invoke(orderNo, stockCode, price, qty, isBuy) + + // 매수 체결 시 즉시 해당 종목 실시간 시세 구독 시작 + if (isBuy) subscribeStock(stockCode) + } + } + + /** + * 자동매매 목표가 도달 여부 판단 + */ + private fun checkAutoTradeTargets(code: String, currentPrice: Double) { + // DB에서 해당 종목의 감시 설정(익절/손절가)을 가져와 비교 + // 효율성을 위해 Map 등에 캐싱하여 사용할 것을 권장 + scope.launch(Dispatchers.IO) { + val config = DatabaseFactory.findConfigByCode(code) ?: return@launch + + if (currentPrice >= config.targetPrice) { + withContext(Dispatchers.Main) { onTargetReached?.invoke(code, currentPrice, true) } + } else if (currentPrice <= config.stopLossPrice) { + withContext(Dispatchers.Main) { onTargetReached?.invoke(code, currentPrice, false) } } } - - } - private fun updatePriceWithEffect(newPrice: String) { - val oldPrice = currentPrice.value.replace(",", "").toIntOrNull() ?: 0 - val current = newPrice.toIntOrNull() ?: 0 - - currentPrice.value = String.format("%, d", current) - priceChangeColor.value = when { - current > oldPrice -> Color.Red.copy(alpha = 0.2f) - current < oldPrice -> Color.Blue.copy(alpha = 0.2f) - else -> Color.Transparent - } } /** - * [2] 실시간 시세 구독 (Registration) - * tr_type = "1" (등록) + * 개인 체결 통보 구독 (HTS ID 필요) */ + suspend fun subscribeExecution(htsId: String) { + sendRequest(htsId, trType = "1", trId = "H0STCNI0") + println("📡 실시간 체결 통보 구독 시작: $htsId") + } + suspend fun subscribeStock(stockCode: String) { - sendRequest(stockCode, trType = "1") - println("📡 실시간 시세 구독 시작: $stockCode") + sendRequest(stockCode, trType = "1", trId = "H0STCNT0") } - /** - * [3] 실시간 시세 구독 취소 (Unsubscription) - * tr_type = "2" (해제) - */ suspend fun unsubscribeStock(stockCode: String) { - if (stockCode.isEmpty()) return - sendRequest(stockCode, trType = "2") - println("🚫 실시간 시세 구독 해제: $stockCode") + if (stockCode.isNotEmpty()) sendRequest(stockCode, trType = "2", trId = "H0STCNT0") } - /** - * 공통 요청 전송 함수 - */ - private suspend fun sendRequest(stockCode: String, trType: String) { + private suspend fun sendRequest(key: String, trType: String, trId: String) { val currentSession = session ?: return val config = KisSession.config @@ -148,8 +177,8 @@ class KisWebSocketManager { }, "body": { "input": { - "tr_id": "H0STCNT0", - "tr_key": "$stockCode" + "tr_id": "$trId", + "tr_key": "$key" } } } @@ -158,7 +187,12 @@ class KisWebSocketManager { try { currentSession.send(Frame.Text(requestJson)) } catch (e: Exception) { - println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}") + println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}") } } + + fun clearData() { + tradeLogs.clear() + currentPrice.value = "0" + } } \ No newline at end of file diff --git a/src/main/kotlin/ui/ActiveTradeRow.kt b/src/main/kotlin/ui/ActiveTradeRow.kt new file mode 100644 index 0000000..20f4c0a --- /dev/null +++ b/src/main/kotlin/ui/ActiveTradeRow.kt @@ -0,0 +1,104 @@ +// src/main/kotlin/ui/ActiveTradeRow.kt +package ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.ActiveTradeItem +import model.ActiveTradeType + +@Composable +fun ActiveTradeRow( + item: ActiveTradeItem, + onCancelClick: (String) -> Unit = {}, // 미체결 취소용 + onClick: () -> Unit +) { + val isMonitoring = item.type == ActiveTradeType.MONITORING + + // 상태에 따른 배경색 설정 (미체결은 연노랑으로 강조) + val backgroundColor = if (isMonitoring) Color.White else Color(0xFFFFF9C4) + val badgeColor = if (isMonitoring) Color(0xFF0E62CF) else Color(0xFFE03E2D) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 2.dp) + .clickable { onClick() }, + elevation = 2.dp, + shape = RoundedCornerShape(4.dp), + backgroundColor = backgroundColor + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + // 상태 배지 (자동감시 / 미체결) + Surface( + color = badgeColor, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = if (isMonitoring) "자동감시" else "미체결", + color = Color.White, + fontSize = 10.sp, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = item.name, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + maxLines = 1 + ) + } + Text( + text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}", + fontSize = 11.sp, + color = Color.Gray + ) + } + + // 우측 액션 영역 + Column(horizontalAlignment = Alignment.End) { + if (!isMonitoring) { + // 미체결인 경우 취소 버튼 표시 + Button( + onClick = { onCancelClick(item.id) }, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.height(28.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) + ) { + Text("취소", fontSize = 11.sp) + } + Text( + text = "${item.quantity}주", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFE03E2D) + ) + } else { + // 자동감시 중인 경우 상태 텍스트 표시 + Text( + text = "감시중", + fontSize = 12.sp, + color = badgeColor, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/AutoTradeSection.kt b/src/main/kotlin/ui/AutoTradeSection.kt new file mode 100644 index 0000000..41389dd --- /dev/null +++ b/src/main/kotlin/ui/AutoTradeSection.kt @@ -0,0 +1,100 @@ +// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일) +package ui + +import AutoTradeItem +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.ActiveTradeItem +import model.ActiveTradeType +import network.KisTradeService + +// src/main/kotlin/ui/AutoTradeSection.kt + +@Composable +fun AutoTradeSection( + tradeService: KisTradeService, + refreshTrigger: Int, // 갱신 트리거 추가 + onRefresh: () -> Unit, + onItemSelect: (ActiveTradeItem) -> Unit +) { + // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델) + var combinedList by remember { mutableStateOf(emptyList()) } + + // refreshTrigger가 바뀔 때마다 실행됨 + LaunchedEffect(refreshTrigger) { + // 1. DB에서 감시 중인 종목 로드 + val monitoringItems = DatabaseFactory.getActiveAutoTrades().map { + ActiveTradeItem( + id = it.code, + code = it.code, + name = it.name, + type = ActiveTradeType.MONITORING, + price = it.targetPrice, + quantity = "-", + isDomestic = it.isDomestic + ) + } + + // 2. KIS API에서 미체결 주문 로드 + val unfilledItems = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()).map { + ActiveTradeItem( + id = it.ord_no, + code = it.pdno, + name = it.prdt_name, + type = ActiveTradeType.UNFILLED, + price = it.ord_unpr.toDouble(), + quantity = it.rmnd_qty, + isDomestic = true + ) + } + + combinedList = monitoringItems + unfilledItems + } + + Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) + + // 강제 갱신 버튼 + IconButton( + onClick = onRefresh, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Default.Refresh, + contentDescription = "새로고침", + tint = Color(0xFF0E62CF), + modifier = Modifier.size(18.dp) + ) + } + } + LazyColumn { + items(combinedList) { item -> + ActiveTradeRow( + item = item, + onCancelClick = { orderNo -> + // tradeService.cancelOrder(orderNo, item.code) 호출 로직 + }, + onClick = { + onItemSelect(item) // 상세 화면 전환용 콜백 + } + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/AutoTradeSettingCard.kt b/src/main/kotlin/ui/AutoTradeSettingCard.kt new file mode 100644 index 0000000..356dd4f --- /dev/null +++ b/src/main/kotlin/ui/AutoTradeSettingCard.kt @@ -0,0 +1,80 @@ +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 AutoTradeSettingCard(stockCode: String, currentPrice: String) { + var profitRate by remember { mutableStateOf("5.0") } + var stopLossRate by remember { mutableStateOf("-3.0") } + var isEnabled by remember { mutableStateOf(false) } + + Card( + elevation = 4.dp, + shape = RoundedCornerShape(8.dp), + backgroundColor = Color(0xFFF8F9FA) // detail.html의 order-box 배경색 참고 + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2) + Spacer(modifier = Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = profitRate, + onValueChange = { profitRate = it }, + label = { Text("익절 %") }, + modifier = Modifier.weight(1f).padding(end = 4.dp), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp) + ) + OutlinedTextField( + value = stopLossRate, + onValueChange = { stopLossRate = it }, + label = { Text("손절 %") }, + modifier = Modifier.weight(1f), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { isEnabled = !isEnabled }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF) + ) + ) { + Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/CandleChart.kt b/src/main/kotlin/ui/CandleChart.kt index 2feb391..8d24a09 100644 --- a/src/main/kotlin/ui/CandleChart.kt +++ b/src/main/kotlin/ui/CandleChart.kt @@ -17,22 +17,20 @@ fun CandleChart(data: List, modifier: Modifier = Modifier) { 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 maxDisplayCount = 50 + val candleWidth = width / maxOf(data.size, maxDisplayCount) + val spacing = candleWidth * 0.2f + + // 가격 범위 계산 (여백 추가) val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 } val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 } - val priceRange = maxPrice - minPrice + val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 } + val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백 - // priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지 - fun getY(price: Double): Float { - if (priceRange == 0.0) return height / 2f - return (height - ((price - minPrice) / priceRange * height)).toFloat() - } + fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat() -// 루프 내부에서도 동일하게 적용 data.forEachIndexed { index, candle -> val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0 val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0 diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index e9057fb..b1cddad 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -1,6 +1,7 @@ // src/main/kotlin/ui/DashboardScreen.kt package ui +import AutoTradeItem import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -9,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import model.KisSession import network.KisTradeService import network.KisWebSocketManager @@ -17,20 +19,76 @@ import network.KisWebSocketManager fun DashboardScreen() { val tradeService = remember { KisTradeService() } val wsManager = remember { KisWebSocketManager() } - + val config = KisSession.config + val scope = rememberCoroutineScope() +// 데이터 갱신을 위한 트리거 상태 + var refreshTrigger by remember { mutableStateOf(0) } // 전역 상태: 현재 선택된 종목 정보 var selectedStockCode by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") } var isDomestic by remember { mutableStateOf(true) } - // 초기 웹소켓 연결 LaunchedEffect(Unit) { + // 1. 웹소켓 연결 wsManager.connect() + + // 2. 체결 통보 콜백 설정 (매수 성공 시 감시 시작) + wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy -> + if (isBuy) { + // [매수 체결 시] DB에 감시 데이터 저장 + // 주의: targetPrice와 stopLossPrice는 이전에 설정된 값을 가져오거나 + // 임시 상태값에서 가져와야 함 (여기선 예시로 현재가의 +5%, -3% 설정) + val execPrice = price.toDoubleOrNull() ?: 0.0 + DatabaseFactory.saveAutoTrade( + AutoTradeItem( + code = code, + name = "", // 필요 시 종목명 매핑 + targetPrice = execPrice * 1.05, + stopLossPrice = execPrice * 0.97, + status = "MONITORING", + isDomestic = true + ) + ) + println("📝 매수 체결로 인한 자동 감시 등록: $code") + } else { + // [매도 체결 시] 감시 종료 및 DB 삭제 + DatabaseFactory.deleteAutoTrade(code) + println("✅ 매도 체결로 인한 감시 종료: $code") + } + refreshTrigger++ + } + + // 3. 목표가 도달 콜백 설정 (자동 매도 실행) + wsManager.onTargetReached = { code, price, isProfit -> + scope.launch { + println("🚀 목표가 도달! 자동 매도 주문 실행: $code (이유: ${if(isProfit) "익절" else "손절"})") + + // 실제 매도 주문 API 호출 + tradeService.postOrder( + stockCode = code, + qty = "1", // 실제론 보유 수량을 가져와야 함 + price = "0", // 시장가 매도 + isBuy = false + ).onSuccess { + // 매도 주문 성공 시 로그 기록 + DatabaseFactory.saveTradeLog( + code, "", "매도", price, 1, + if(isProfit) "AI 익절 조건 달성" else "AI 손절 조건 달성" + ) + } + } + refreshTrigger++ + } + + if (config.htsId.isNotEmpty()) { + wsManager.subscribeExecution(config.htsId) + println("📡 HTS ID(${config.htsId})로 체결 통보 구독을 시작합니다.") + } } Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { // [좌측 25%] 내 자산 및 통합 잔고 - Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { BalanceSection(tradeService) { code, name, isDom -> selectedStockCode = code selectedStockName = name @@ -59,9 +117,20 @@ fun DashboardScreen() { } VerticalDivider() - + Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { + AutoTradeSection( + tradeService = tradeService, + onRefresh = { refreshTrigger++ }, + refreshTrigger = refreshTrigger // 트리거 전달 + ) { item -> + selectedStockCode = item.code + selectedStockName = item.name + isDomestic = item.isDomestic + } + } + VerticalDivider() // [우측 30%] 시장 추천 TOP 20 (실전 데이터) - Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { MarketSection(tradeService) { code, name, isDom -> selectedStockCode = code selectedStockName = name diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt new file mode 100644 index 0000000..0a7aefd --- /dev/null +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -0,0 +1,142 @@ +// src/main/kotlin/ui/IntegratedOrderSection.kt +package ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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 kotlinx.coroutines.launch +import network.KisTradeService + +@Composable +fun IntegratedOrderSection( + stockCode: String, + currentPrice: String, + tradeService: KisTradeService, + onOrderResult: (String, Boolean) -> Unit +) { + val scope = rememberCoroutineScope() + var orderQty by remember { mutableStateOf("1") } + var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 + + // 자동 매도 설정 + var isAutoSellEnabled by remember { mutableStateOf(false) } + var profitRate by remember { mutableStateOf("5.0") } + var stopLossRate by remember { mutableStateOf("-3.0") } + + val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0 + val qty = orderQty.toDoubleOrNull() ?: 0.0 + + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) + + // 1. 가격 및 수량 입력 + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + OutlinedTextField( + value = orderQty, + onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it }, + label = { Text("수량") }, + modifier = Modifier.weight(1f).padding(end = 4.dp) + ) + OutlinedTextField( + value = orderPrice, + onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it }, + label = { Text("가격") }, + placeholder = { Text("시장가 (${currentPrice})") }, + modifier = Modifier.weight(1f) + ) + } + + // 2. 수익률 시뮬레이션 표 (신규 추가) + if (basePrice > 0 && qty > 0) { + Text("익절/손절 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 11.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 4.dp)) + Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) { + Column(modifier = Modifier.padding(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%"), true) + SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }, false) + SimulationColumn("예상수령액", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate -> + val sellPrice = basePrice * rate + val totalAmount = sellPrice * qty + val netAmount = totalAmount * (1 - 0.0022) // 수수료+세금 약 0.22% 차감 + String.format("%,d", netAmount.toLong()) + }, false) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 3. 자동 매도 옵션 + Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) { + Column(modifier = Modifier.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isAutoSellEnabled, onCheckedChange = { isAutoSellEnabled = it }) + Text("매수 체결 시 자동 매도 감시 시작", fontSize = 12.sp) + } + if (isAutoSellEnabled) { + Row { + OutlinedTextField( + value = profitRate, onValueChange = { profitRate = it }, + label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp) + ) + OutlinedTextField( + value = stopLossRate, onValueChange = { stopLossRate = it }, + label = { Text("손절 %") }, modifier = Modifier.weight(1f) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 4. 매수/매도 버튼 + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + scope.launch { + val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice + tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) + .onSuccess { + onOrderResult(it, true) + if (isAutoSellEnabled) { /* 자동매도 등록 로직 호출 */ } + } + .onFailure { onOrderResult(it.message ?: "에러", false) } + } + }, + modifier = Modifier.weight(1f).padding(end = 4.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) + ) { Text("매수", color = Color.White) } + + Button( + onClick = { /* 매도 로직동일 */ }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) + ) { Text("매도", color = Color.White) } + } + } +} + +@Composable +fun SimulationColumn(title: String, items: List, isHeader: Boolean) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray) + items.forEach { text -> + Text( + text = text, + fontSize = 11.sp, + color = if (text.contains("+")) Color(0xFFE03E2D) else if (text.contains("-")) Color(0xFF0E62CF) else Color.Black, + modifier = Modifier.padding(vertical = 1.dp) + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/PeriodTrendCard.kt b/src/main/kotlin/ui/PeriodTrendCard.kt new file mode 100644 index 0000000..5de5354 --- /dev/null +++ b/src/main/kotlin/ui/PeriodTrendCard.kt @@ -0,0 +1,57 @@ +// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합) +package ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import model.CandleData + +@Composable +fun PeriodTrendCard(label: String, data: List, modifier: Modifier = Modifier) { + val avgPrice = if (data.isEmpty()) "0" + else String.format("%,d", data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average().toLong()) + + Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + // [좌측] 라벨 및 평균가 + Column(modifier = Modifier.weight(0.4f)) { + Text(label, fontSize = 10.sp, color = Color.Gray) + Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + + // [우측] 간소화된 그래프 (Sparkline) + Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) { + if (data.isNotEmpty()) { + Canvas(modifier = Modifier.fillMaxSize()) { + val prices = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 } + val max = prices.maxOrNull() ?: 1.0 + val min = prices.minOrNull() ?: 0.0 + val range = if (max == min) 1.0 else max - min + + val stepX = size.width / (prices.size - 1).coerceAtLeast(1) + val points = prices.mapIndexed { i, p -> + Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat()) + } + + for (i in 0 until points.size - 1) { + drawLine( + color = if (prices.last() >= prices.first()) Color(0xFFE03E2D) else Color(0xFF0E62CF), + start = points[i], + end = points[i + 1], + strokeWidth = 2f + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index ee93388..9908930 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -54,7 +54,13 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { Text("모의투자") } Divider(Modifier.padding(vertical = 12.dp)) - + OutlinedTextField( + value = config.htsId, + onValueChange = { config = config.copy(htsId = it) }, + label = { Text("HTS ID (실시간 체결 통보용)") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") } + ) // 실전 3종 입력 Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold) OutlinedTextField(value = config.realAccountNo, onValueChange = { diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 8ac97fc..5c6c1e6 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -22,6 +22,7 @@ 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.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.AppConfig @@ -41,10 +42,29 @@ fun StockDetailSection( tradeService: KisTradeService, wsManager: KisWebSocketManager ) { + + var openPrice by remember { mutableStateOf("0") } var chartData by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(false) } var resultMessage by remember { mutableStateOf("") } var isSuccess by remember { mutableStateOf(true) } + var daySummary by remember { mutableStateOf>(emptyList()) } + var weekSummary by remember { mutableStateOf>(emptyList()) } + var monthSummary by remember { mutableStateOf>(emptyList()) } + var yearSummary by remember { mutableStateOf>(emptyList()) } + + val todayOpen = remember(daySummary) { + daySummary.lastOrNull()?.stck_oprc ?: "0" + } + val previousClose = remember(daySummary) { + if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_clpr else "0" + } + + fun calculateAvg(data: List): String { + if (data.isEmpty()) return "0" + val avg = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average() + return String.format("%,d", avg.toLong()) + } // 이전 종목 코드를 기억하기 위한 상태 var previousCode by remember { mutableStateOf("") } @@ -59,49 +79,103 @@ fun StockDetailSection( if (previousCode.isNotEmpty()) { wsManager.unsubscribeStock(previousCode) } + wsManager.clearData() wsManager.subscribeStock(stockCode) previousCode = stockCode // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) - tradeService.fetchChartData(stockCode, isDomestic) - .onSuccess { data -> - println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 - chartData = data - } - .onFailure { error -> - println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") - chartData = emptyList() - } + coroutineScope { + launch {tradeService.fetchChartData(stockCode, isDomestic) + .onSuccess { data -> + println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 + chartData = data + } + .onFailure { error -> + println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") + chartData = emptyList() + }} + launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { daySummary = it.takeLast(7) } } // 최근 7일 + launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } } // 최근 4주 + launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { + monthSummary = it.takeLast(6) // 최근 6개월 + yearSummary = it.takeLast(36) // 최근 3년 + } } + } isLoading = false } 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 - ) + // 현재 시간(분 단위) 확인 + val currentMinute = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HHmm00")) - chartData = chartData.dropLast(1) + updatedCandle - println("chartData.size $chartData.size") + if (lastCandle.stck_bsop_date != currentMinute) { + // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과) + val newCandle = CandleData( + stck_bsop_date = currentMinute, + stck_oprc = latestPrice, + stck_hgpr = latestPrice, + stck_lwpr = latestPrice, + stck_clpr = latestPrice, + acml_vol = "0" + ) + // 최대 100개까지만 유지하여 성능 최적화 + chartData = (chartData + newCandle).takeLast(100) + } else { + // 같은 분 내에서는 기존 마지막 캔들만 업데이트 + 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 + } } } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { // [상단] 종목명 및 상태 메시지 - StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + StockHeader( + name = stockName, + code = stockCode, + isDomestic = isDomestic, + previousClose = previousClose, + openPrice = openPrice, + resultMessage = resultMessage, + isSuccess = isSuccess + ) + // 실시간 가격 표시 (WebSocket 데이터) + Column(horizontalAlignment = Alignment.End) { + Text( + text = "${wsManager.currentPrice.value} 원", + style = MaterialTheme.typography.h4, + fontWeight = FontWeight.Bold, + color = if (wsManager.currentPrice.value.contains("-")) Color.Blue else Color.Red + ) + Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray) + } + } + // 통합된 트렌드 카드 배치 + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PeriodTrendCard("7일", daySummary, Modifier.weight(1f)) + PeriodTrendCard("4주", weekSummary, Modifier.weight(1f)) + PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f)) + PeriodTrendCard("3년", yearSummary, Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(10.dp)) // [중앙] 캔들 차트 (Card 내부) Card( modifier = Modifier.fillMaxWidth().height(300.dp), @@ -136,14 +210,27 @@ fun StockDetailSection( Spacer(modifier = Modifier.width(12.dp)) // 주문 섹션 (인자 간소화) - OrderSection( - stockCode = stockCode, - currentPrice = wsManager.currentPrice.value, - onOrderResult = { msg, success -> - resultMessage = msg - isSuccess = success - } - ) + Column(modifier = Modifier.weight(0.6f)) { + IntegratedOrderSection( + stockCode = stockCode, + currentPrice = wsManager.currentPrice.value, + tradeService = tradeService, + onOrderResult = { msg, success -> + resultMessage = msg + isSuccess = success + } + ) + } + } + } +} + +@Composable +fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) { + Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) { + Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, fontSize = 10.sp, color = Color.Gray) + Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black) } } } \ No newline at end of file diff --git a/src/main/kotlin/ui/StockHeader.kt b/src/main/kotlin/ui/StockHeader.kt index 36a2b62..d8adaa7 100644 --- a/src/main/kotlin/ui/StockHeader.kt +++ b/src/main/kotlin/ui/StockHeader.kt @@ -2,6 +2,7 @@ package ui import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,64 +18,52 @@ fun StockHeader( name: String, code: String, isDomestic: Boolean, + previousClose: String, // 추가: 전일 종가 + openPrice: String, // 추가: 금일 시가 resultMessage: String, isSuccess: Boolean ) { - Column(modifier = Modifier.fillMaxWidth()) { - // [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시) + Column(modifier = Modifier.wrapContentWidth()) { + // [1] 알림 메시지 영역 (기존 동일) if (resultMessage.isNotEmpty()) { Surface( - color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강 + color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) + shape = RoundedCornerShape(4.dp) ) { - Text( - text = resultMessage, - color = Color.White, - modifier = Modifier.padding(8.dp), - textAlign = TextAlign.Center, - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) + Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold) } } - // [2] 종목명 및 국가 배지 영역 - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 4.dp) - ) { - // 국가 구분 배지 + // [2] 종목명 및 정보 + Row(verticalAlignment = Alignment.CenterVertically) { Surface( - color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑 - shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp) + color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), + 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) - ) + Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) } - Spacer(modifier = Modifier.width(8.dp)) - - // 종목 이름 - Text( - text = name, - style = MaterialTheme.typography.h5, - fontWeight = FontWeight.Bold - ) - + Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.width(6.dp)) + Text(text = "($code)", color = Color.Gray) + } - // 종목 코드 - Text( - text = "($code)", - style = MaterialTheme.typography.body1, - color = Color.Gray - ) + // [3] 전일 종가 및 시가 정보 행 추가 + Row(modifier = Modifier.padding(top = 4.dp)) { + PriceSummaryItem("전일 종가", previousClose) + Spacer(modifier = Modifier.width(16.dp)) + PriceSummaryItem("금일 시가", openPrice) } } +} + +@Composable +fun PriceSummaryItem(label: String, price: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = label, fontSize = 11.sp, color = Color.Gray) + Spacer(modifier = Modifier.width(4.dp)) + val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price + Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium) + } } \ No newline at end of file diff --git a/src/main/kotlin/ui/SummaryGraphCard.kt b/src/main/kotlin/ui/SummaryGraphCard.kt new file mode 100644 index 0000000..1e7bb53 --- /dev/null +++ b/src/main/kotlin/ui/SummaryGraphCard.kt @@ -0,0 +1,53 @@ +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 +import model.CandleData +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke + + +@Composable +fun SummaryGraphCard(label: String, data: List, modifier: Modifier = Modifier) { + Card(modifier = modifier.height(60.dp), elevation = 2.dp, backgroundColor = Color.White) { + Column(modifier = Modifier.padding(4.dp)) { + Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.Gray) + + if (data.isNotEmpty()) { + Canvas(modifier = Modifier.fillMaxSize()) { + val prices = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 } + val max = prices.maxOrNull() ?: 1.0 + val min = prices.minOrNull() ?: 0.0 + val range = if (max == min) 1.0 else max - min + + val stepX = size.width / (prices.size - 1).coerceAtLeast(1) + val points = prices.mapIndexed { i, p -> + Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat()) + } + + // 추세선 그리기 + for (i in 0 until points.size - 1) { + drawLine( + color = if (prices.last() >= prices.first()) Color.Red else Color.Blue, + start = points[i], + end = points[i + 1], + strokeWidth = 2f + ) + } + } + } + } + } +} \ No newline at end of file