diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index c24bc5d..3f3a185 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -44,6 +44,8 @@ fun main() = application { ) } } + + isLoaded = true } @@ -69,7 +71,6 @@ fun main() = application { ) } AppScreen.Dashboard -> { - // 이제 모든 서비스는 KisSession.config를 전역 참조함 DashboardScreen() } } diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 51efca9..05ec296 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -1,3 +1,5 @@ +import AutoTradeTable.orderNo +import kotlinx.serialization.Serializable import model.AppConfig import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq @@ -6,6 +8,14 @@ import org.jetbrains.exposed.sql.transactions.transaction import java.io.File import java.time.LocalDateTime +object TradeStatus { + const val PENDING_BUY = "PENDING_BUY" // 매수 주문 중 + const val MONITORING = "MONITORING" // 매수 체결 후 감시 중 + const val SELLING = "SELLING" // 손절/익절 매도 주문 중 + const val EXPIRED = "EXPIRED" // 서버와 불일치 (유저 판단 대기) + const val COMPLETED = "COMPLETED" // 거래 종료 +} + // 1. 앱 설정 테이블 object ConfigTable : Table("app_config") { val id = integer("id").autoIncrement() @@ -26,13 +36,18 @@ 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 quantity = integer("quantity").default(0) + val profitRate = double("profit_rate").default(0.0) + val stopLossRate = double("stop_loss_rate").default(0.0) + val targetPrice = double("target_price").default(0.0) + val stopLossPrice = double("stop_loss_price").default(0.0) + val orderNo = varchar("order_no", 50).uniqueIndex() + val status = varchar("status", 20).default("PENDING_BUY") val isDomestic = bool("is_domestic").default(true) override val primaryKey = PrimaryKey(id) } + // 3. 거래 내역 테이블 object TradeLogTable : Table("trade_logs") { val id = long("id").autoIncrement() @@ -62,59 +77,70 @@ object DatabaseFactory { // --- 자동매매(감시) 관련 함수 --- + /** - * [추가] 종목코드로 현재 감시 중인 설정 가져오기 (웹소켓 감시용) + * 새로운 자동매매 건 등록 (주로 PENDING_BUY 상태로 시작) + */ + fun saveAutoTrade(item: AutoTradeItem) = transaction { + AutoTradeTable.insert { + it[stockCode] = item.code + it[stockName] = item.name + it[quantity] = item.quantity + it[profitRate] = item.profitRate + it[stopLossRate] = item.stopLossRate + it[targetPrice] = item.targetPrice + it[stopLossPrice] = item.stopLossPrice + it[orderNo] = item.orderNo + it[status] = item.status + it[isDomestic] = item.isDomestic + } + } + + /** + * 상태 변경 및 가격 업데이트 (예: PENDING_BUY -> MONITORING) + */ + fun updateAutoTrade(item: AutoTradeItem) = transaction { + val id = item.id ?: return@transaction + AutoTradeTable.update({ AutoTradeTable.id eq id }) { + it[targetPrice] = item.targetPrice + it[stopLossPrice] = item.stopLossPrice + it[orderNo] = item.orderNo + it[status] = item.status + } + } + + /** + * 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용) + */ + fun getActiveAutoTrades(): List = transaction { + AutoTradeTable.select { + AutoTradeTable.status inList listOf("MONITORING", "SELLING", "PENDING_BUY") + }.map { mapToAutoTradeItem(it) } + } + + /** + * 종목코드로 현재 감시 중인 설정이 있는지 확인 (UI 체크박스 상태용) */ fun findConfigByCode(code: String): AutoTradeItem? = transaction { AutoTradeTable.select { (AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING") - }.lastOrNull()?.let { - mapToAutoTradeItem(it) - } + }.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(id: Int) = transaction { + AutoTradeTable.deleteWhere { AutoTradeTable.id eq id } } - /** - * [추가] 매도 완료 또는 취소 시 감시 대상 삭제 - */ - 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( + id = it[AutoTradeTable.id], code = it[AutoTradeTable.stockCode], name = it[AutoTradeTable.stockName], + quantity = it[AutoTradeTable.quantity], + profitRate = it[AutoTradeTable.profitRate], + stopLossRate = it[AutoTradeTable.stopLossRate], targetPrice = it[AutoTradeTable.targetPrice], stopLossPrice = it[AutoTradeTable.stopLossPrice], + orderNo = it[AutoTradeTable.orderNo], status = it[AutoTradeTable.status], isDomestic = it[AutoTradeTable.isDomestic] ) @@ -169,16 +195,90 @@ object DatabaseFactory { } } } + + fun saveOrUpdate(item: AutoTradeItem) = transaction { + val existing = AutoTradeTable.select { AutoTradeTable.orderNo eq item.orderNo }.firstOrNull() + if (existing == null) { + AutoTradeTable.insert { + it[orderNo] = item.orderNo + it[stockCode] = item.code + it[stockName] = item.name + it[status] = item.status + it[targetPrice] = item.targetPrice + it[stopLossPrice] = item.stopLossPrice + it[quantity] = item.quantity + it[isDomestic] = item.isDomestic + } + } else { + AutoTradeTable.update({ AutoTradeTable.orderNo eq item.orderNo }) { + it[status] = item.status + it[targetPrice] = item.targetPrice + it[stopLossPrice] = item.stopLossPrice + } + } + } + + /** + * 주문번호로 항목 조회 (가장 핵심적인 식별자) + */ + fun findByOrderNo(orderNo: String): AutoTradeItem? = transaction { + AutoTradeTable.select { AutoTradeTable.orderNo eq orderNo } + .map { mapToAutoTradeItem(it) } + .singleOrNull() + } + + /** + * 서버 동기화: DB에는 PENDING_BUY/MONITORING인데 서버 미체결 내역에 없는 경우 EXPIRED로 변경 + */ + fun syncWithServer(serverOrderNos: List) = transaction { + AutoTradeTable.update({ + (AutoTradeTable.status inList listOf(TradeStatus.PENDING_BUY, TradeStatus.MONITORING)) and + (AutoTradeTable.orderNo notInList serverOrderNos) + }) { + it[status] = TradeStatus.EXPIRED + } + } + + /** + * 상태 업데이트 및 주문번호 갱신 (예: 매수체결 시 신규 익절 주문번호로 교체) + */ + fun updateStatusAndOrderNo(id: Int, newStatus: String, newOrderNo: String? = null) = transaction { + AutoTradeTable.update({ AutoTradeTable.id eq id }) { + it[status] = newStatus + if (newOrderNo != null) it[orderNo] = newOrderNo + } + } + + /** + * 감시 중인 모든 종목 리스트 (Status별 필터링 용이하게 수정) + */ + fun getAutoTradesByStatus(statusList: List): List = transaction { + AutoTradeTable.select { AutoTradeTable.status inList statusList } + .map { mapToAutoTradeItem(it) } + } + } -/** - * [수정] 감시 가격(익절/손절) 정보를 포함하도록 모델 확장 - */ +@Serializable data class AutoTradeItem( - val code: String, - val name: String, - val targetPrice: Double, - val stopLossPrice: Double, // 손절가 추가 - val status: String, - val isDomestic: Boolean + val id: Int? = null, // DB 식별자 + val orderNo: String, // 핵심 키: KIS 주문번호 (odno) + val code: String, // 종목 코드 + val name: String, // 종목 명 + + // 상태 머신 (PENDING_BUY, MONITORING, SELLING, EXPIRED, COMPLETED) + var status: String = "PENDING_BUY", + + // 가격 정보 + val orderedPrice: Double = 0.0, // 주문 단가 + var targetPrice: Double = 0.0, // 익절 목표가 + var stopLossPrice: Double = 0.0, // 손절 목표가 + + // 수량 정보 + val quantity: Int = 0, // 총 주문 수량 + var remainedQuantity: Int = 0, // 미체결 잔량 (서버 동기화용) + + val isDomestic: Boolean = true, + val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율 + val stopLossRate: Double = 0.0 ) \ No newline at end of file diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index 5866b24..7d60502 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -39,7 +39,7 @@ data class AppConfig( // [신규] 전역에서 참조할 단일 세션 객체 object KisSession { var config: AppConfig = AppConfig() - + fun getWebSocketKey() = config.websocketToken // 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주) fun isMarketTokenValid(): Boolean { return config.marketToken.isNotEmpty() && diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index cd7bdf4..5258ebf 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -1,5 +1,6 @@ package model +import AutoTradeItem import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -121,15 +122,14 @@ data class UnifiedBalance( @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: 매수) + val orgn_odno: String, + @SerialName("odno") val ord_no: String, // JSON의 odno를 ord_no로 매핑 + val pdno: String, + @SerialName("prdt_name") val prdt_name: String, + val ord_unpr: String, // JSON이 문자열이므로 String 권장 + @SerialName("psbl_qty") val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑 + val ord_dvsn_name: String, + val rvse_cncl_dvsn_name: String ) @Serializable @@ -139,15 +139,23 @@ data class UnfilledResponse( val output: List = emptyList() ) -// src/main/kotlin/model/TradeModels.kt 내 추가 -enum class ActiveTradeType { MONITORING, UNFILLED } +fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem { + return AutoTradeItem( + orderNo = this.ord_no, + code = this.pdno, + name = this.prdt_name, + orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0, + quantity = 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리 + remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0, + status = "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태 + isDomestic = isDomestic + ) +} -data class ActiveTradeItem( - val id: String, // DB ID 또는 주문번호 +// 단순 정보 전달용 데이터 클래스 +data class StockBasicInfo( val code: String, val name: String, - val type: ActiveTradeType, - val price: Double, // 목표가 또는 주문단가 - val quantity: String, // 미체결 수량 (감시 중에는 "-") - val isDomestic: Boolean + val isDomestic: Boolean, + val quantity: String = "0" ) \ No newline at end of file diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index 674b82d..bd62553 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -1,5 +1,6 @@ package network +import AutoTradeItem import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.CIO @@ -323,21 +324,45 @@ class KisTradeService { 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")) + if (rtCd == "0") { + // 응답의 output 객체에서 주문 번호(ODNO) 추출 + val orderNo = body["output"]?.jsonObject?.get("ODNO")?.jsonPrimitive?.content + ?: body["output"]?.jsonObject?.get("odno")?.jsonPrimitive?.content // API마다 대소문자가 다를 수 있음 + ?: "" + Result.success(orderNo) // 성공 시 주문 번호 반환 + } else { + val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음" + Result.failure(Exception("❌ 오류 ($rtCd): $msg")) + } } catch (e: Exception) { Result.failure(e) } } + fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem { + return AutoTradeItem( + orderNo = this.ord_no, + code = this.pdno, + name = this.prdt_name, + orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0, + quantity = 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리 + remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0, + status = "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태 + isDomestic = isDomestic + ) + } /** * [추가] 국내 미체결 내역 조회 */ suspend fun fetchUnfilledOrders(): Result> { val config = KisSession.config - if (config.isSimulation) Result.success(emptyList()) + if (config.isSimulation) return Result.success(emptyList()) val baseUrl = if (config.isSimulation) vtsUrl else prodUrl val trId = "TTTC0084R" + var pureAccount = config.accountNo.replace("-", "").trim() + if (pureAccount.length == 8) pureAccount += "01" + val cano = pureAccount.take(8) + val acntPrdtCd = pureAccount.takeLast(2) return try { val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl") { header("authorization", "Bearer ${config.tradeToken}") @@ -346,8 +371,8 @@ class KisTradeService { header("tr_id", trId) header("custtype", "P") - parameter("CANO", config.accountNo.take(8)) - parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2)) + parameter("CANO", cano) + parameter("ACNT_PRDT_CD", acntPrdtCd) parameter("CTX_AREA_FK100", "") parameter("CTX_AREA_NK100", "") parameter("T_GUBUN", "0") @@ -356,8 +381,16 @@ class KisTradeService { parameter("INQR_DVSN_1", "0") parameter("INQR_DVSN_2", "0") } + println("result >> ${response.status}") val body = response.body() - if (body.rt_cd == "0") Result.success(body.output) + println("result >> ${body.msg1}") + println("result >> ${body.rt_cd}") + if (body.rt_cd == "0") { + + var result = body + println("result >> ${result.output.size}") + Result.success(result.output) + } else Result.failure(Exception(body.msg1)) } catch (e: Exception) { Result.failure(e) } } @@ -369,8 +402,13 @@ class KisTradeService { val config = KisSession.config val baseUrl = if (config.isSimulation) vtsUrl else prodUrl val trId = if (config.isSimulation) "VTTC0803U" else "TTTC0803U" + var pureAccount = config.accountNo.replace("-", "").trim() + if (pureAccount.length == 8) pureAccount += "01" + val cano = pureAccount.take(8) + val acntPrdtCd = pureAccount.takeLast(2) return try { + println("orgNo") 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) @@ -379,14 +417,15 @@ class KisTradeService { header("Content-Type", "application/json") setBody(mapOf( - "CANO" to config.accountNo.take(8), - "ACNT_PRDT_CD" to config.accountNo.takeLast(2), + "CANO" to cano, + "ACNT_PRDT_CD" to acntPrdtCd, "KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호 - "ORGN_ORD_NO" to orgNo, // 취소할 원주문번호 - "RVSE_CNCL_DVSN" to "02", // 01: 정정, 02: 취소 + "ORGN_ODNO" to orgNo, // 취소할 원주문번호 + "RVSE_CNCL_DVSN_CD" to "02", // 01: 정정, 02: 취소 "ORD_DVSN" to "00", // 지정가 "ORD_QTY" to "0", // 0이면 전량 취소 - "ORD_UNPR" to "0" + "ORD_UNPR" to "0", + "QTY_ALL_ORD_YN" to "Y", )) } val body = response.body() @@ -472,14 +511,19 @@ class KisTradeService { val config = KisSession.config val baseUrl = if (config.isSimulation) vtsUrl else prodUrl val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R" + var pureAccount = config.accountNo.replace("-", "").trim() + if (pureAccount.length == 8) pureAccount += "01" + + val cano = pureAccount.take(8) + val acntPrdtCd = pureAccount.takeLast(2) return try { val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { 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) - parameter("CANO", config.accountNo.take(8)) - parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2)) + parameter("CANO", cano) + parameter("ACNT_PRDT_CD", acntPrdtCd) parameter("AFHR_FLPR_YN", "N") parameter("OFL_YN", "N") parameter("INQR_DVSN", "02") diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt index 3136210..614a0ee 100644 --- a/src/main/kotlin/network/KisWebSocketManager.kt +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -1,198 +1,127 @@ package network -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -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.websocket.* import io.ktor.http.* import io.ktor.websocket.* import kotlinx.coroutines.* -import kotlinx.coroutines.flow.consumeAsFlow import model.KisSession -import model.RealTimeTrade -import model.TradeType +import java.util.concurrent.atomic.AtomicBoolean class KisWebSocketManager { - private val client = HttpClient(CIO) { - install(WebSockets) { pingInterval = 20_000 } - install(HttpTimeout) { - requestTimeoutMillis = 15_000 - connectTimeoutMillis = 15_000 - } - } - + private val client = HttpClient { install(WebSockets) } private var session: DefaultClientWebSocketSession? = null - private val scope = CoroutineScope(Dispatchers.Default + Job()) + private val isConnected = AtomicBoolean(false) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - // UI 상태값 - val currentPrice = mutableStateOf("0") - 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 + // 콜백 리스너 + var onPriceUpdate: ((String, Double) -> Unit)? = null + var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? = null suspend fun connect() { - val config = KisSession.config - if (config.websocketToken.isEmpty()) return - - val hostUrl = "ops.koreainvestment.com" - val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름) + if (isConnected.get()) return + val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:21000" else "ops.koreainvestment.com:31000" scope.launch { try { - client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") { + client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") { session = this - println("✅ 웹소켓 서버 연결 성공") + isConnected.set(true) + println("✅ 웹소켓 연결 성공") - incoming.consumeAsFlow().collect { frame -> - if (frame is Frame.Text) { - parseTradeData(frame.readText()) - } + // 연결 직후 HTS ID 기반 체결 통보 자동 구독 + val htsId = KisSession.config.htsId + if (htsId.isNotEmpty()) sendRequest("1", "H0STT084R", htsId) + + for (frame in incoming) { + if (frame is Frame.Text) handleMessage(frame.readText()) } } } catch (e: Exception) { - println("❌ 웹소켓 연결 오류: ${e.localizedMessage}") + println("❌ 웹소켓 에러: ${e.message}") + } finally { + isConnected.set(false) } } } - private fun parseTradeData(data: String) { - // KIS 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터 - val parts = data.split("|") - if (parts.size < 4) return + private val _currentPrice = mutableStateOf("0") + val currentPrice = _currentPrice + + val tradeLogs = androidx.compose.runtime.mutableStateListOf() + + suspend fun unsubscribeStock(code: String) { + subscribeStock(code, isSubscribe = false) + } + + private fun handleMessage(message: String) { + if (!message.startsWith("0") && !message.startsWith("1")) return + val parts = message.split("|") + if (parts.size < 4) return val trId = parts[1] - val body = parts[3] + val dataRows = parts[3].split("^") when (trId) { - "H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리 - "H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리 - } - } + "H0STCNT0" -> { + val price = dataRows[2] + _currentPrice.value = price // 상태 업데이트 + onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0) - /** - * [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) } + // 로그 추가 (예시) + tradeLogs.add(0, model.RealTimeTrade( + time = dataRows[1], + price = price, + change = dataRows[4], + volume = dataRows[2], + type = model.TradeType.NEUTRAL + )) + if (tradeLogs.size > 50) tradeLogs.removeLast() } + "H0STT084R" -> onExecutionReceived?.invoke(dataRows[5], dataRows[9], dataRows[12], dataRows[13], dataRows[15] == "02") } } - - /** - * 개인 체결 통보 구독 (HTS ID 필요) - */ - suspend fun subscribeExecution(htsId: String) { - sendRequest(htsId, trType = "1", trId = "H0STCNI0") - println("📡 실시간 체결 통보 구독 시작: $htsId") - } - - suspend fun subscribeStock(stockCode: String) { - sendRequest(stockCode, trType = "1", trId = "H0STCNT0") - } - - suspend fun unsubscribeStock(stockCode: String) { - if (stockCode.isNotEmpty()) sendRequest(stockCode, trType = "2", trId = "H0STCNT0") - } - - private suspend fun sendRequest(key: String, trType: String, trId: String) { - val currentSession = session ?: return - val config = KisSession.config - - val requestJson = """ - { - "header": { - "approval_key": "${config.websocketToken}", - "custtype": "P", - "tr_type": "$trType", - "content-type": "utf-8" - }, - "body": { - "input": { - "tr_id": "$trId", - "tr_key": "$key" - } - } - } - """.trimIndent() - - try { - currentSession.send(Frame.Text(requestJson)) - } catch (e: Exception) { - println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}") - } - } - fun clearData() { tradeLogs.clear() - currentPrice.value = "0" + _currentPrice.value = "0" } + + suspend fun subscribeStock(code: String, isSubscribe: Boolean = true) { + val trType = if (isSubscribe) "1" else "2" + sendRequest(trType, "H0STCNT0", code) + if (isSubscribe) println("📡 구독 등록: $code") else println("📴 구독 해제: $code") + } + + private suspend fun sendRequest(trType: String, trId: String, trKey: String) { + val approvalKey = KisSession.getWebSocketKey() ?: return + val json = """ + { + "header": { + "approval_key": "$approvalKey", + "custtype": "P", + "tr_type": "$trType", + "content-type": "utf-8" + }, + "body": { + "input": { "tr_id": "$trId", "tr_key": "$trKey" } + } + } + """.trimIndent() + session?.send(json) + } + private val activeSubscriptions = mutableSetOf() // 현재 구독 중인 종목 코드 관리 + suspend fun updateSubscriptions(requiredCodes: Set) { + // 해지할 종목 (현재 구독 중이나 요구 리스트에 없는 것) + val toUnsubscribe = activeSubscriptions - requiredCodes + toUnsubscribe.forEach { subscribeStock(it, isSubscribe = false) } + + // 신규 구독 (요구 리스트에는 있으나 현재 구독 중이 아닌 것) + val toSubscribe = requiredCodes - activeSubscriptions + toSubscribe.forEach { subscribeStock(it, isSubscribe = true) } + + activeSubscriptions.clear() + activeSubscriptions.addAll(requiredCodes) + } + } \ No newline at end of file diff --git a/src/main/kotlin/ui/ActiveTradeRow.kt b/src/main/kotlin/ui/ActiveTradeRow.kt index 20f4c0a..de58b7e 100644 --- a/src/main/kotlin/ui/ActiveTradeRow.kt +++ b/src/main/kotlin/ui/ActiveTradeRow.kt @@ -1,6 +1,7 @@ // src/main/kotlin/ui/ActiveTradeRow.kt package ui +import AutoTradeItem import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -13,20 +14,21 @@ 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 = {}, // 미체결 취소용 + item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용 + onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달 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) + // 상태에 따른 UI 구성 요소 정의 + val (statusText, statusColor, backgroundColor) = when (item.status) { + "PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑 + "MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑 + "SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강 + "COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색 + else -> Triple("알 수 없음", Color.Black, Color.White) + } Card( modifier = Modifier @@ -39,65 +41,67 @@ fun ActiveTradeRow( ) { Row( modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + verticalAlignment = Alignment.CenterVertically ) { + // 좌측 정보 영역 Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - // 상태 배지 (자동감시 / 미체결) + // 상태 배지 표시 Surface( - color = badgeColor, - shape = RoundedCornerShape(4.dp) + color = statusColor, + shape = RoundedCornerShape(2.dp), + modifier = Modifier.padding(end = 6.dp) ) { Text( - text = if (isMonitoring) "자동감시" else "미체결", + text = statusText, color = Color.White, - fontSize = 10.sp, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + fontSize = 9.sp, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + fontWeight = FontWeight.Bold ) } - Spacer(modifier = Modifier.width(6.dp)) Text( text = item.name, + style = MaterialTheme.typography.body2, fontWeight = FontWeight.Bold, - fontSize = 14.sp, maxLines = 1 ) } + + // 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시) + val detailText = when (item.status) { + "PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%" + "MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}" + else -> "주문번호: ${item.orderNo}" + } + Text( - text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}", + text = "${item.code} | $detailText", fontSize = 11.sp, color = Color.Gray ) } - // 우측 액션 영역 + // 우측 액션 및 수량 영역 Column(horizontalAlignment = Alignment.End) { - if (!isMonitoring) { - // 미체결인 경우 취소 버튼 표시 + if (item.status == "PENDING_BUY" || item.status == "SELLING") { + // 진행 중인 주문인 경우 취소 버튼 노출 Button( - onClick = { onCancelClick(item.id) }, + onClick = { onCancelClick() }, 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 - ) } + + Text( + text = "${item.quantity}주", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = statusColor + ) } } } diff --git a/src/main/kotlin/ui/AutoTradeSection.kt b/src/main/kotlin/ui/AutoTradeSection.kt index 41389dd..d36e161 100644 --- a/src/main/kotlin/ui/AutoTradeSection.kt +++ b/src/main/kotlin/ui/AutoTradeSection.kt @@ -15,51 +15,35 @@ 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 model.toAutoTradeItem import network.KisTradeService // src/main/kotlin/ui/AutoTradeSection.kt @Composable fun AutoTradeSection( + isDomestic: Boolean, tradeService: KisTradeService, refreshTrigger: Int, // 갱신 트리거 추가 onRefresh: () -> Unit, - onItemSelect: (ActiveTradeItem) -> Unit + onItemSelect: (AutoTradeItem) -> Unit, + onItemCancel: (AutoTradeItem) -> Unit ) { // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델) - var combinedList by remember { mutableStateOf(emptyList()) } - + var tradeList 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 - ) - } + val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList() - // 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 - ) - } + // 2. DB에서 로컬 감시 데이터 가져오기 + val localTrades = DatabaseFactory.getActiveAutoTrades() - combinedList = monitoringItems + unfilledItems + // 3. 동기화 로직: 서버에 없는 주문번호를 가진 로컬 데이터는 EXPIRED로 표시 + tradeList = localTrades.map { local -> + if (local.status != "COMPLETED" && serverUnfilled.none { it.orderNo == local.orderNo }) { + local.copy(status = "EXPIRED") + } else local + } } Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { @@ -84,15 +68,11 @@ fun AutoTradeSection( } } LazyColumn { - items(combinedList) { item -> + items(tradeList) { item -> ActiveTradeRow( item = item, - onCancelClick = { orderNo -> - // tradeService.cancelOrder(orderNo, item.code) 호출 로직 - }, - onClick = { - onItemSelect(item) // 상세 화면 전환용 콜백 - } + onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용 + onClick = { onItemSelect(item) } ) } } diff --git a/src/main/kotlin/ui/AutoTradeSettingCard.kt b/src/main/kotlin/ui/AutoTradeSettingCard.kt index 356dd4f..82a5375 100644 --- a/src/main/kotlin/ui/AutoTradeSettingCard.kt +++ b/src/main/kotlin/ui/AutoTradeSettingCard.kt @@ -1,80 +1,116 @@ -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 +//package ui +// +//import AutoTradeItem +//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, +// stockName: String, // 종목명 추가 +// currentPrice: String, +// isDomestic: Boolean = true +//) { +// var profitRate by remember { mutableStateOf("5.0") } +// var stopLossRate by remember { mutableStateOf("-3.0") } +// +// // DB에서 현재 감시 중인지 확인 +// var isEnabled by remember(stockCode) { +// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null) +// } +// +// Card( +// elevation = 4.dp, +// shape = RoundedCornerShape(8.dp), +// backgroundColor = Color(0xFFF8F9FA) +// ) { +// 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 = { +// if (!isEnabled) { +// // 자동 매매 시작: DB 저장 +// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 +// val target = curPriceNum * (1 + profitRate.toDouble() / 100.0) +// val stopLoss = curPriceNum * (1 + stopLossRate.toDouble() / 100.0) +// +// DatabaseFactory.saveAutoTrade( +// AutoTradeItem( +// code = stockCode, +// name = stockName, +// targetPrice = target, +// stopLossPrice = stopLoss, +// status = "MONITORING", +// isDomestic = isDomestic +// ) +// ) +// // [중요] 웹소켓 실시간 감시 등록 로직이 이곳에 호출되어야 함 +// // KisWebSocketManager.subscribe(stockCode) +// isEnabled = true +// } else { +// // 자동 매매 중단: DB 삭제 +// DatabaseFactory.deleteAutoTrade(stockCode) +// // KisWebSocketManager.unsubscribe(stockCode) +// isEnabled = false +// } +// }, +// 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/BalanceSection.kt b/src/main/kotlin/ui/BalanceSection.kt index 9f98eb8..a7471d5 100644 --- a/src/main/kotlin/ui/BalanceSection.kt +++ b/src/main/kotlin/ui/BalanceSection.kt @@ -6,8 +6,11 @@ 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 @@ -17,13 +20,15 @@ import network.KisTradeService @Composable fun BalanceSection( tradeService: KisTradeService, - onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit + refreshTrigger: Int, // 갱신 트리거 추가 + onRefresh: () -> Unit, + onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit ) { var balanceData by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(false) } // 화면 진입 시 및 갱신 시 데이터 로드 - LaunchedEffect(Unit) { + LaunchedEffect(refreshTrigger) { isLoading = true tradeService.fetchIntegratedBalance().onSuccess { balanceData = it @@ -34,13 +39,30 @@ fun BalanceSection( } Column(modifier = Modifier.fillMaxSize()) { - Text( - text = "나의 자산", - style = MaterialTheme.typography.h6, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp) - ) - + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "나의 자산", + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + // 강제 갱신 버튼 + 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) + ) + } + } // 1. 자산 요약 카드 BalanceSummaryCard(balanceData) @@ -62,7 +84,7 @@ fun BalanceSection( LazyColumn(modifier = Modifier.weight(1f, true)) { items(balanceData?.holdings ?: emptyList()) { holding -> UnifiedStockItemRow(holding) { - onStockSelect(holding.code, holding.name, holding.isDomestic) + onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity) } } } @@ -78,6 +100,7 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) { modifier = Modifier.fillMaxWidth(), backgroundColor = androidx.compose.ui.graphics.Color.White ) { + Column(modifier = Modifier.padding(16.dp)) { Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) Text( @@ -103,6 +126,9 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) { @Composable fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) { + val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0 + val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0 + Card( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, elevation = 1.dp @@ -126,19 +152,27 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) } Spacer(Modifier.width(4.dp)) Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) + Text( + "매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}", + fontSize = 11.sp, color = Color.Gray + ) } Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) } - Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { - Text("${holding.currentPrice} 원") + Column(horizontalAlignment = Alignment.End) { + Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold) + // 손익분기점 표시 추가 + Text( + "손익분기: ${String.format("%,.0f", breakEvenPrice)}원", + fontSize = 10.sp, color = Color(0xFF666666) + ) val rate = holding.profitRate.toDoubleOrNull() ?: 0.0 Text( - text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%", - color = if (rate > 0) androidx.compose.ui.graphics.Color.Red - else if (rate < 0) androidx.compose.ui.graphics.Color.Blue - else androidx.compose.ui.graphics.Color.DarkGray, - fontSize = 12.sp + text = "${if (rate > 0) "+" else ""}${holding.profitRate}%", + color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray, + fontSize = 12.sp, + fontWeight = FontWeight.Bold ) } } diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index b1cddad..c1d4240 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -12,87 +12,80 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import model.KisSession +import model.StockBasicInfo import network.KisTradeService import network.KisWebSocketManager +import util.MarketUtil @Composable 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) } + var selectedStockQuantity by remember { mutableStateOf("0") } + + var selectedItem by remember { mutableStateOf(null) } // 감시/미체결 아이템 선택 시 + var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 + + 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") - } + // 2. [기동 시 동기화 시나리오] + scope.launch { + // (1) 서버 미체결 내역 로드 + val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()) + val serverOrderNos = serverOrders.map { it.ord_no } + + // (2) DB 상태 대조 및 EXPIRED 전환 + DatabaseFactory.syncWithServer(serverOrderNos) + + // (3) 활성 감시 종목 구독 재개 + val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) + val monitoringCodes = monitoringTrades.map { it.code }.toSet() + wsManager.updateSubscriptions(monitoringCodes) + refreshTrigger++ } - // 3. 목표가 도달 콜백 설정 (자동 매도 실행) - wsManager.onTargetReached = { code, price, isProfit -> + // 3. 실시간 체결 통보 핸들러 (주문번호 중심) + wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy -> 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 손절 조건 달성" - ) + val dbItem = DatabaseFactory.findByOrderNo(orderNo) + if (dbItem != null) { + when (dbItem.status) { + TradeStatus.PENDING_BUY -> { + // 매수 주문 체결 -> 감시(MONITORING)로 전환 및 익절 주문 발주 + // 여기서 익절 주문 후 받은 신규 주문번호를 DB에 갱신 + // updateStatusAndOrderNo(dbItem.id!!, TradeStatus.MONITORING, newSellOrderNo) + } + TradeStatus.SELLING -> { + // 매도(손절/익절) 주문 체결 -> COMPLETED + DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) + } + } + refreshTrigger++ } } - 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.18f).fillMaxHeight().padding(8.dp)) { - BalanceSection(tradeService) { code, name, isDom -> + BalanceSection(tradeService, + onRefresh = { refreshTrigger++ }, + refreshTrigger = refreshTrigger) { code, name, isDom,qty -> selectedStockCode = code selectedStockName = name isDomestic = isDom + selectedStockQuantity = qty println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") } } @@ -105,6 +98,7 @@ fun DashboardScreen() { StockDetailSection( stockCode = selectedStockCode, stockName = selectedStockName, + holdingQuantity = selectedStockQuantity, isDomestic = isDomestic, tradeService = tradeService, wsManager = wsManager @@ -119,19 +113,33 @@ fun DashboardScreen() { VerticalDivider() Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { AutoTradeSection( + isDomestic = isDomestic, tradeService = tradeService, onRefresh = { refreshTrigger++ }, - refreshTrigger = refreshTrigger // 트리거 전달 - ) { item -> - selectedStockCode = item.code - selectedStockName = item.name - isDomestic = item.isDomestic - } + refreshTrigger = refreshTrigger , // 트리거 전달 + onItemCancel = { item -> + scope.launch { + tradeService.cancelOrder(item.orderNo,item.code).onSuccess { + refreshTrigger++ + } + } + }, + onItemSelect = { item -> + selectedStockCode = item.code + selectedStockName = item.name + isDomestic = item.isDomestic + }) } VerticalDivider() // [우측 30%] 시장 추천 TOP 20 (실전 데이터) Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { MarketSection(tradeService) { code, name, isDom -> + val info = StockBasicInfo( + code = code, + name = name, + isDomestic = isDom + ) + selectedStockInfo = info selectedStockCode = code selectedStockName = name isDomestic = isDom diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 0a7aefd..73d6f2d 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -1,6 +1,7 @@ // src/main/kotlin/ui/IntegratedOrderSection.kt package ui +import AutoTradeItem import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,29 +16,56 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import network.KisTradeService +/** + * 통합 주문 및 자동매매 설정 섹션 + * * [수정 사항] + * 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지 + * 2. 수량 입력 시 콤마 제거 및 Int 변환 예외 처리 적용 + * 3. 매수 성공 시 반환받은 실제 주문번호(ODNO)를 DB에 저장하여 주문번호 중심 관리 구현 + */ @Composable fun IntegratedOrderSection( stockCode: String, + stockName: String, + isDomestic: Boolean, currentPrice: String, + holdingQuantity: String, tradeService: KisTradeService, onOrderResult: (String, Boolean) -> Unit ) { val scope = rememberCoroutineScope() - var orderQty by remember { mutableStateOf("1") } + + // 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리 + var monitoringItem by remember(stockCode) { + mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) + } + + val isAutoSellEnabled = monitoringItem != null + + // UI 입력 상태 var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 + var orderQty by remember(holdingQuantity) { + // 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리) + val cleanQty = holdingQuantity.replace(",", "") + mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty) + } - // 자동 매도 설정 - var isAutoSellEnabled by remember { mutableStateOf(false) } - var profitRate by remember { mutableStateOf("5.0") } - var stopLossRate by remember { mutableStateOf("-3.0") } + var profitRate by remember(monitoringItem) { + mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0") + } + var stopLossRate by remember(monitoringItem) { + mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-3.0") + } - val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0 - val qty = orderQty.toDoubleOrNull() ?: 0.0 + // 계산용 변수 + val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0 + val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0) + val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 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, @@ -54,71 +82,121 @@ fun IntegratedOrderSection( ) } - // 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) - } - } - } + // 수익률 시뮬레이션 표 + if (basePrice > 0 && inputQty > 0) { + SimulationCard(basePrice, inputQty.toDouble()) } Spacer(modifier = Modifier.height(12.dp)) - // 3. 자동 매도 옵션 + // 실시간 AI 매도 감시 설정 카드 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) + Checkbox( + checked = isAutoSellEnabled, + onCheckedChange = { checked -> + if (!checked) { + // [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지) + monitoringItem?.id?.let { dbId -> + DatabaseFactory.deleteAutoTrade(dbId) + monitoringItem = null + println("🗑️ 감시 해제: $stockName (ID: $dbId)") + } + } else { + // [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작 + if (curPriceNum > 0) { + val pRate = profitRate.toDoubleOrNull() ?: 0.0 + val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 + val target = curPriceNum * (1 + pRate / 100.0) + val stop = curPriceNum * (1 + sRate / 100.0) + + val newItem = AutoTradeItem( + orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}", + code = stockCode, + name = stockName, + quantity = inputQty, + profitRate = pRate, + stopLossRate = sRate, + targetPrice = target, + stopLossPrice = stop, + status = "MONITORING", + isDomestic = isDomestic + ) + DatabaseFactory.saveAutoTrade(newItem) + monitoringItem = DatabaseFactory.findConfigByCode(stockCode) + } + } + } + ) + Text("실시간 AI 매도 감시 활성화", fontSize = 12.sp, fontWeight = FontWeight.Bold) } - 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) - ) - } + + Row { + OutlinedTextField( + value = profitRate, onValueChange = { profitRate = it }, + label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp), + enabled = !isAutoSellEnabled + ) + OutlinedTextField( + value = stopLossRate, onValueChange = { stopLossRate = it }, + label = { Text("손절 %") }, modifier = Modifier.weight(1f), + enabled = !isAutoSellEnabled + ) } } } 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) { /* 자동매도 등록 로직 호출 */ } + .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 + onOrderResult("주문 성공: $realOrderNo", true) + if (isAutoSellEnabled) { + val pRate = profitRate.toDoubleOrNull() ?: 0.0 + val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 + DatabaseFactory.saveAutoTrade(AutoTradeItem( + orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙) + code = stockCode, + name = stockName, + quantity = inputQty, + profitRate = pRate, + stopLossRate = sRate, + targetPrice = basePrice * (1 + pRate / 100.0), + stopLossPrice = basePrice * (1 + sRate / 100.0), + status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태 + isDomestic = isDomestic + )) + monitoringItem = DatabaseFactory.findConfigByCode(stockCode) + } } - .onFailure { onOrderResult(it.message ?: "에러", false) } + .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 = { /* 매도 로직동일 */ }, + onClick = { + scope.launch { + val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice + tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = false) + .onSuccess { realOrderNo -> + onOrderResult("매도 주문 성공: $realOrderNo", true) + // 매도 시 기존 감시 설정이 있다면 상태 변경 등 추가 로직 가능 + } + .onFailure { onOrderResult(it.message ?: "매도 실패", false) } + } + }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) ) { Text("매도", color = Color.White) } @@ -127,16 +205,33 @@ fun IntegratedOrderSection( } @Composable -fun SimulationColumn(title: String, items: List, isHeader: Boolean) { +fun SimulationCard(basePrice: Double, qty: Double) { + Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) { + Column(modifier = Modifier.padding(8.dp)) { + Text("수익률 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 10.sp, color = Color.Gray) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%")) + SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }) + SimulationColumn("예상수령", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate -> + val netAmount = (basePrice * rate * qty) * (1 - 0.0022) + String.format("%,d", netAmount.toLong()) + }) + } + } + } +} + +@Composable +fun SimulationColumn(title: String, items: List) { 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) - ) + val color = when { + text.contains("+") -> Color(0xFFE03E2D) + text.contains("-") -> Color(0xFF0E62CF) + else -> Color.Black + } + Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp)) } } } \ No newline at end of file diff --git a/src/main/kotlin/ui/MyStockList.kt b/src/main/kotlin/ui/MyStockList.kt deleted file mode 100644 index a0f08bd..0000000 --- a/src/main/kotlin/ui/MyStockList.kt +++ /dev/null @@ -1,115 +0,0 @@ -package ui - - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* -import io.ktor.client.engine.cio.CIO -// 아래 두 import가 'delegate' 에러를 해결합니다. -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import model.AppConfig -import model.BalanceSummary -import model.RankingStock -import model.StockHolding -import network.KisTradeService - -@Composable -fun MyStockList( - holdings: List, - onSelect: (code: String, name: String) -> Unit -) { - if (holdings.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("보유 종목이 없습니다.", color = Color.Gray, style = MaterialTheme.typography.body2) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(holdings) { stock -> - MyStockItemRow(stock) { - onSelect(stock.pdno, stock.prdt_name) - } - } - } - } -} - -@Composable -fun MyStockItemRow( - stock: StockHolding, - onClick: () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() }, - elevation = 1.dp, - shape = RoundedCornerShape(4.dp), - backgroundColor = Color.White - ) { - Row( - modifier = Modifier.padding(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // 1. 종목명 및 코드 - Column(modifier = Modifier.weight(1f)) { - Text( - text = stock.prdt_name, - style = MaterialTheme.typography.body2, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stock.pdno, - style = MaterialTheme.typography.caption, - color = Color.Gray - ) - } - - // 2. 수익률 배지 (웹 소스 컬러 적용) - val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0 - val color = when { - rate > 0 -> Color(0xFFE03E2D) // 매수색 - rate < 0 -> Color(0xFF0E62CF) // 매도색 - else -> Color.DarkGray - } - - Column(horizontalAlignment = Alignment.End) { - Surface( - color = color.copy(alpha = 0.1f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = "${if (rate > 0) "+" else ""}${stock.evlu_pfls_rt}%", - color = color, - style = MaterialTheme.typography.caption, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) - ) - } - Text( - text = "${stock.prpr}원", - style = MaterialTheme.typography.caption, - color = Color.DarkGray - ) - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/ui/OrderSection.kt b/src/main/kotlin/ui/OrderSection.kt deleted file mode 100644 index f810360..0000000 --- a/src/main/kotlin/ui/OrderSection.kt +++ /dev/null @@ -1,93 +0,0 @@ -package ui - - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* -import io.ktor.client.engine.cio.CIO -// 아래 두 import가 'delegate' 에러를 해결합니다. -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import model.AppConfig -import model.BalanceSummary -import model.CandleData -import model.RankingStock -import model.StockHolding -import network.KisTradeService -import network.KisWebSocketManager -import kotlin.collections.isNotEmpty - -@Composable -fun OrderSection( - stockCode: String, - currentPrice: String, - onOrderResult: (String, Boolean) -> Unit -) { - val scope = rememberCoroutineScope() - val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전 - var orderQty by remember { mutableStateOf("1") } - var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가 - - Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) { - Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp) - - OutlinedTextField( - value = orderQty, - onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it }, - label = { Text("수량") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = if(orderPrice == "0") "시장가" else orderPrice, - onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it }, - label = { Text("가격") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = { - scope.launch { - // KisSession을 사용하는 postOrder 호출 - tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true) - .onSuccess { onOrderResult(it, true) } - .onFailure { onOrderResult(it.message ?: "에러", false) } - } - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) - ) { - Text("매수", color = Color.White) - } - - Button( - onClick = { - scope.launch { - tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false) - .onSuccess { onOrderResult(it, true) } - .onFailure { onOrderResult(it.message ?: "에러", false) } - } - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) - ) { - Text("매도", color = Color.White) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/ui/RecommendationTabs.kt b/src/main/kotlin/ui/RecommendationTabs.kt deleted file mode 100644 index 6ac66a2..0000000 --- a/src/main/kotlin/ui/RecommendationTabs.kt +++ /dev/null @@ -1,152 +0,0 @@ -//package ui -// -// -//import androidx.compose.foundation.BorderStroke -//import androidx.compose.foundation.background -//import androidx.compose.foundation.clickable -//import androidx.compose.foundation.layout.* -//import androidx.compose.foundation.lazy.LazyColumn -//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 -//import androidx.compose.foundation.lazy.itemsIndexed -//import androidx.compose.foundation.shape.RoundedCornerShape -//import androidx.compose.material.* -//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset -//import androidx.compose.runtime.* -//import io.ktor.client.engine.cio.CIO -//// 아래 두 import가 'delegate' 에러를 해결합니다. -//import androidx.compose.runtime.getValue -//import androidx.compose.runtime.setValue -//import androidx.compose.ui.Alignment -//import androidx.compose.ui.Modifier -//import androidx.compose.ui.graphics.Color -//import androidx.compose.ui.text.font.FontWeight -//import androidx.compose.ui.text.style.TextOverflow -//import androidx.compose.ui.unit.dp -//import androidx.compose.ui.unit.sp -//import kotlinx.coroutines.launch -//import model.AppConfig -//import model.BalanceSummary -//import model.RankingStock -//import model.RankingType -//import model.StockHolding -//import network.KisTradeService -//import util.MarketUtil -// -//@Composable -//fun RecommendationTabs( -// config: AppConfig, -// token: String, -// onSelect: (String, String) -> Unit -//) { -// var isDomestic by remember { mutableStateOf(true) } -// var selectedType by remember { mutableStateOf(RankingType.RISE) } -// var rankingList by remember { mutableStateOf>(emptyList()) } -// -// val tradeService = remember { KisTradeService(config.isSimulation) } -// val isKoreaOpen = MarketUtil.isKoreanMarketOpen() -// var errorMessage by remember { mutableStateOf(null) } // 에러 메시지 상태 추가 -// -// // 데이터 로드 로직 -// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) { -// errorMessage = null // 로딩 시작 시 에러 초기화 -// if (isDomestic) { -// if (isKoreaOpen) { -// tradeService.fetchMarketRanking(token, config, selectedType, true) -// .onSuccess { rankingList = it.take(20) } -// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." } -// } else { -// tradeService.fetchDomesticPreviousDayRanking(token, config) -// .onSuccess { rankingList = it } -// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" } -// } -// } else { -// tradeService.fetchOverseasRanking(token, config) -// .onSuccess { rankingList = it } -// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." } -// } -// } -// -// Column(modifier = Modifier.fillMaxSize()) { -// // [1] 국내/해외 전환 버튼 (항상 노출) -// Row(Modifier.fillMaxWidth().padding(8.dp)) { -// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true } -// Spacer(Modifier.width(8.dp)) -// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false } -// } -// -// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출) -// ScrollableTabRow( -// selectedTabIndex = selectedType.ordinal, -// edgePadding = 8.dp, -// backgroundColor = Color.White, -// indicator = { tabPositions -> -// TabRowDefaults.Indicator( -// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]), -// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF) -// ) -// } -// ) { -// RankingType.values().forEach { type -> -// Tab( -// selected = selectedType == type, -// onClick = { selectedType = type }, -// text = { Text(type.title, fontSize = 12.sp) } -// ) -// } -// } -// -// // [3] 장외 시간 안내 바 -// if (isDomestic && !isKoreaOpen) { -// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) { -// Text( -// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.", -// fontSize = 11.sp, modifier = Modifier.padding(8.dp) -// ) -// } -// } -// -// // [4] 추천 리스트 영역 -// Box(modifier = Modifier.weight(1f)) { -// if (errorMessage != null) { -// // 에러 발생 시 안내 -// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) { -// Text(errorMessage!!, color = Color.Gray) -// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") } -// } -// } else if (rankingList.isEmpty()) { -// // 로딩 중 -// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { -// CircularProgressIndicator() -// } -// } else { -// // [성공] 리스트 노출 -// LazyColumn { -// itemsIndexed(rankingList) { index, stock -> -// RankingItemRow(index, stock, isDomestic, selectedType) { -// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm) -// } -// } -// } -// } -// } -// } -//} -// -//@Composable -//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) { -// OutlinedButton( -// onClick = onClick, -// colors = ButtonDefaults.outlinedButtonColors( -// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent -// ), -// modifier = Modifier.height(36.dp), -// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray) -// ) { -// Text( -// text = title, -// color = if (isSelected) activeColor else Color.Gray, -// fontSize = 12.sp, -// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal -// ) -// } -//} diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 5c6c1e6..9db5b04 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -38,6 +38,7 @@ import kotlin.collections.isNotEmpty fun StockDetailSection( stockCode: String, stockName: String, + holdingQuantity : String, isDomestic: Boolean, tradeService: KisTradeService, wsManager: KisWebSocketManager @@ -153,6 +154,7 @@ fun StockDetailSection( previousClose = previousClose, openPrice = openPrice, resultMessage = resultMessage, + resultMessageClear = {resultMessage = ""}, isSuccess = isSuccess ) @@ -213,7 +215,10 @@ fun StockDetailSection( Column(modifier = Modifier.weight(0.6f)) { IntegratedOrderSection( stockCode = stockCode, + stockName = stockName, + isDomestic = isDomestic, currentPrice = wsManager.currentPrice.value, + holdingQuantity = holdingQuantity, tradeService = tradeService, onOrderResult = { msg, success -> resultMessage = msg diff --git a/src/main/kotlin/ui/StockHeader.kt b/src/main/kotlin/ui/StockHeader.kt index d8adaa7..797325c 100644 --- a/src/main/kotlin/ui/StockHeader.kt +++ b/src/main/kotlin/ui/StockHeader.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +@OptIn(ExperimentalMaterialApi::class) @Composable fun StockHeader( name: String, @@ -21,6 +22,7 @@ fun StockHeader( previousClose: String, // 추가: 전일 종가 openPrice: String, // 추가: 금일 시가 resultMessage: String, + resultMessageClear : ()->Unit, isSuccess: Boolean ) { Column(modifier = Modifier.wrapContentWidth()) { @@ -28,8 +30,11 @@ fun StockHeader( if (resultMessage.isNotEmpty()) { Surface( color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - shape = RoundedCornerShape(4.dp) + modifier = Modifier.padding(bottom = 8.dp), + shape = RoundedCornerShape(4.dp), + onClick = { + resultMessageClear.invoke() + } ) { Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold) } diff --git a/src/main/kotlin/ui/SummaryGraphCard.kt b/src/main/kotlin/ui/SummaryGraphCard.kt deleted file mode 100644 index 1e7bb53..0000000 --- a/src/main/kotlin/ui/SummaryGraphCard.kt +++ /dev/null @@ -1,53 +0,0 @@ -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 diff --git a/src/main/kotlin/util/AppConfigManager.kt b/src/main/kotlin/util/AppConfigManager.kt deleted file mode 100644 index 0ccf6ab..0000000 --- a/src/main/kotlin/util/AppConfigManager.kt +++ /dev/null @@ -1,38 +0,0 @@ -package util - -import java.io.File -import java.util.Properties -object AppConfigManager { - private val props = Properties() - private val configFile = File("app.properties") - - // API 및 계좌 설정 - var appKey: String by PropertyDelegate("app_key", "") - var secretKey: String by PropertyDelegate("secret_key", "") - var accountNo: String by PropertyDelegate("account_no", "") - var isSimulation: Boolean by PropertyDelegate("is_simulation", "true") { it.toBoolean() } - - // AI 모델 설정 - var modelPath: String by PropertyDelegate("model_path", "") - - init { - if (configFile.exists()) configFile.inputStream().use { props.load(it) } - } - - private fun save() = configFile.outputStream().use { props.store(it, "AutoTrade Config") } - - // 델리게이트 패턴으로 중복 코드 방지 - class PropertyDelegate( - private val key: String, - private val default: String, - private val parser: (String) -> T = { it as T } - ) { - operator fun getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): T = - parser(props.getProperty(key, default)) - - operator fun setValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: T) { - props.setProperty(key, value.toString()) - save() - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/util/MarketUtil.kt b/src/main/kotlin/util/MarketUtil.kt index 978cd21..771eb5d 100644 --- a/src/main/kotlin/util/MarketUtil.kt +++ b/src/main/kotlin/util/MarketUtil.kt @@ -14,4 +14,18 @@ object MarketUtil { return now.isAfter(start) && now.isBefore(end) } + fun roundToTickSize(price: Double): Double { + val tick = when { + price < 2000 -> 1.0 + price < 5000 -> 5.0 + price < 20000 -> 10.0 + price < 50000 -> 50.0 + price < 200000 -> 100.0 + price < 500000 -> 500.0 + else -> 1000.0 + } + // 가장 가까운 호가 단위로 반올림 + return Math.round(price / tick) * tick + } + } \ No newline at end of file