This commit is contained in:
lunaticbum 2026-01-19 17:09:37 +09:00
parent bdc268e325
commit 91f9e4ee9a
20 changed files with 786 additions and 974 deletions

View File

@ -44,6 +44,8 @@ fun main() = application {
)
}
}
isLoaded = true
}
@ -69,7 +71,6 @@ fun main() = application {
)
}
AppScreen.Dashboard -> {
// 이제 모든 서비스는 KisSession.config를 전역 참조함
DashboardScreen()
}
}

View File

@ -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<AutoTradeItem> = 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<AutoTradeItem> = 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<String>) = 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<String>): List<AutoTradeItem> = 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
)

View File

@ -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() &&

View File

@ -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<UnfilledOrder> = 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"
)

View File

@ -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<List<UnfilledOrder>> {
val config = KisSession.config
if (config.isSimulation) Result.success(emptyList<UnfilledOrder>())
if (config.isSimulation) return Result.success(emptyList<UnfilledOrder>())
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<UnfilledResponse>()
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<JsonObject>()
@ -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")

View File

@ -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<RealTimeTrade>()
// 콜백: 체결 발생 시 (주문번호, 종목코드, 가격, 수량, 매수/매도여부)
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("|")
private val _currentPrice = mutableStateOf("0")
val currentPrice = _currentPrice
val tradeLogs = androidx.compose.runtime.mutableStateListOf<model.RealTimeTrade>()
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)
// 로그 추가 (예시)
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")
}
}
/**
* [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())
}
fun clearData() {
tradeLogs.clear()
_currentPrice.value = "0"
}
/**
* [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)
}
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 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) }
}
}
}
/**
* 개인 체결 통보 구독 (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 = """
private suspend fun sendRequest(trType: String, trId: String, trKey: String) {
val approvalKey = KisSession.getWebSocketKey() ?: return
val json = """
{
"header": {
"approval_key": "${config.websocketToken}",
"approval_key": "$approvalKey",
"custtype": "P",
"tr_type": "$trType",
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": "$trId",
"tr_key": "$key"
}
"input": { "tr_id": "$trId", "tr_key": "$trKey" }
}
}
""".trimIndent()
try {
currentSession.send(Frame.Text(requestJson))
} catch (e: Exception) {
println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}")
session?.send(json)
}
private val activeSubscriptions = mutableSetOf<String>() // 현재 구독 중인 종목 코드 관리
suspend fun updateSubscriptions(requiredCodes: Set<String>) {
// 해지할 종목 (현재 구독 중이나 요구 리스트에 없는 것)
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)
}
fun clearData() {
tradeLogs.clear()
currentPrice.value = "0"
}
}

View File

@ -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)
color = statusColor
)
} else {
// 자동감시 중인 경우 상태 텍스트 표시
Text(
text = "감시중",
fontSize = 12.sp,
color = badgeColor,
fontWeight = FontWeight.Medium
)
}
}
}
}

View File

@ -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<ActiveTradeItem>()) }
var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
// 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) }
)
}
}

View File

@ -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)
}
}
}
}
//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)
// }
// }
// }
//}

View File

@ -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<UnifiedBalance?>(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()) {
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
)
}
}

View File

@ -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<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(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")
}
refreshTrigger++
}
// 3. 목표가 도달 콜백 설정 (자동 매도 실행)
wsManager.onTargetReached = { code, price, isProfit ->
// 2. [기동 시 동기화 시나리오]
scope.launch {
println("🚀 목표가 도달! 자동 매도 주문 실행: $code (이유: ${if(isProfit) "익절" else "손절"})")
// (1) 서버 미체결 내역 로드
val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
val serverOrderNos = serverOrders.map { it.ord_no }
// 실제 매도 주문 API 호출
tradeService.postOrder(
stockCode = code,
qty = "1", // 실제론 보유 수량을 가져와야 함
price = "0", // 시장가 매도
isBuy = false
).onSuccess {
// 매도 주문 성공 시 로그 기록
DatabaseFactory.saveTradeLog(
code, "", "매도", price, 1,
if(isProfit) "AI 익절 조건 달성" else "AI 손절 조건 달성"
)
// (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.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
scope.launch {
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++
}
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 ->
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

View File

@ -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)")
}
if (isAutoSellEnabled) {
} 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)
}
Row {
OutlinedTextField(
value = profitRate, onValueChange = { profitRate = it },
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp)
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp),
enabled = !isAutoSellEnabled
)
OutlinedTextField(
value = stopLossRate, onValueChange = { stopLossRate = it },
label = { Text("손절 %") }, modifier = Modifier.weight(1f)
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<String>, 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)
)
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<String>) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray)
items.forEach { text ->
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))
}
}
}

View File

@ -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<StockHolding>,
onSelect: (code: String, name: String) -> Unit
) {
if (holdings.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("보유 종목이 없습니다.", color = Color.Gray, style = MaterialTheme.typography.body2)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(holdings) { stock ->
MyStockItemRow(stock) {
onSelect(stock.pdno, stock.prdt_name)
}
}
}
}
}
@Composable
fun MyStockItemRow(
stock: StockHolding,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = 1.dp,
shape = RoundedCornerShape(4.dp),
backgroundColor = Color.White
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. 종목명 및 코드
Column(modifier = Modifier.weight(1f)) {
Text(
text = stock.prdt_name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stock.pdno,
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// 2. 수익률 배지 (웹 소스 컬러 적용)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = when {
rate > 0 -> Color(0xFFE03E2D) // 매수색
rate < 0 -> Color(0xFF0E62CF) // 매도색
else -> Color.DarkGray
}
Column(horizontalAlignment = Alignment.End) {
Surface(
color = color.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rate > 0) "+" else ""}${stock.evlu_pfls_rt}%",
color = color,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
Text(
text = "${stock.prpr}",
style = MaterialTheme.typography.caption,
color = Color.DarkGray
)
}
}
}
}

View File

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

View File

@ -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<List<RankingStock>>(emptyList()) }
//
// val tradeService = remember { KisTradeService(config.isSimulation) }
// val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
// var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
//
// // 데이터 로드 로직
// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
// errorMessage = null // 로딩 시작 시 에러 초기화
// if (isDomestic) {
// if (isKoreaOpen) {
// tradeService.fetchMarketRanking(token, config, selectedType, true)
// .onSuccess { rankingList = it.take(20) }
// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
// } else {
// tradeService.fetchDomesticPreviousDayRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
// }
// } else {
// tradeService.fetchOverseasRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
// }
// }
//
// Column(modifier = Modifier.fillMaxSize()) {
// // [1] 국내/해외 전환 버튼 (항상 노출)
// Row(Modifier.fillMaxWidth().padding(8.dp)) {
// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
// Spacer(Modifier.width(8.dp))
// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
// }
//
// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
// ScrollableTabRow(
// selectedTabIndex = selectedType.ordinal,
// edgePadding = 8.dp,
// backgroundColor = Color.White,
// indicator = { tabPositions ->
// TabRowDefaults.Indicator(
// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
// )
// }
// ) {
// RankingType.values().forEach { type ->
// Tab(
// selected = selectedType == type,
// onClick = { selectedType = type },
// text = { Text(type.title, fontSize = 12.sp) }
// )
// }
// }
//
// // [3] 장외 시간 안내 바
// if (isDomestic && !isKoreaOpen) {
// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
// Text(
// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
// fontSize = 11.sp, modifier = Modifier.padding(8.dp)
// )
// }
// }
//
// // [4] 추천 리스트 영역
// Box(modifier = Modifier.weight(1f)) {
// if (errorMessage != null) {
// // 에러 발생 시 안내
// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
// Text(errorMessage!!, color = Color.Gray)
// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
// }
// } else if (rankingList.isEmpty()) {
// // 로딩 중
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// CircularProgressIndicator()
// }
// } else {
// // [성공] 리스트 노출
// LazyColumn {
// itemsIndexed(rankingList) { index, stock ->
// RankingItemRow(index, stock, isDomestic, selectedType) {
// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
// }
// }
// }
// }
// }
// }
//}
//
//@Composable
//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
// OutlinedButton(
// onClick = onClick,
// colors = ButtonDefaults.outlinedButtonColors(
// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
// ),
// modifier = Modifier.height(36.dp),
// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
// ) {
// Text(
// text = title,
// color = if (isSelected) activeColor else Color.Gray,
// fontSize = 12.sp,
// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
// )
// }
//}

View File

@ -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

View File

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

View File

@ -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<CandleData>, 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
)
}
}
}
}
}
}

View File

@ -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<T>(
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()
}
}
}

View File

@ -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
}
}