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

View File

@ -1,3 +1,5 @@
import AutoTradeTable.orderNo
import kotlinx.serialization.Serializable
import model.AppConfig import model.AppConfig
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
@ -6,6 +8,14 @@ import org.jetbrains.exposed.sql.transactions.transaction
import java.io.File import java.io.File
import java.time.LocalDateTime 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. 앱 설정 테이블 // 1. 앱 설정 테이블
object ConfigTable : Table("app_config") { object ConfigTable : Table("app_config") {
val id = integer("id").autoIncrement() val id = integer("id").autoIncrement()
@ -26,13 +36,18 @@ object AutoTradeTable : Table("auto_trades") {
val id = integer("id").autoIncrement() val id = integer("id").autoIncrement()
val stockCode = varchar("stock_code", 20) val stockCode = varchar("stock_code", 20)
val stockName = varchar("stock_name", 100) val stockName = varchar("stock_name", 100)
val targetPrice = double("target_price") // 익절 목표가 val quantity = integer("quantity").default(0)
val stopLossPrice = double("stop_loss_price") // 손절 목표가 val profitRate = double("profit_rate").default(0.0)
val status = varchar("status", 20).default("MONITORING") // MONITORING, COMPLETED 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) val isDomestic = bool("is_domestic").default(true)
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
// 3. 거래 내역 테이블 // 3. 거래 내역 테이블
object TradeLogTable : Table("trade_logs") { object TradeLogTable : Table("trade_logs") {
val id = long("id").autoIncrement() 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 { fun findConfigByCode(code: String): AutoTradeItem? = transaction {
AutoTradeTable.select { AutoTradeTable.select {
(AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING") (AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING")
}.lastOrNull()?.let { }.lastOrNull()?.let { mapToAutoTradeItem(it) }
mapToAutoTradeItem(it)
}
} }
/** fun deleteAutoTrade(id: Int) = transaction {
* [추가] 매수 체결 새로운 자동매매 감시 대상 등록 AutoTradeTable.deleteWhere { AutoTradeTable.id eq id }
*/
fun saveAutoTrade(item: AutoTradeItem) {
transaction {
// 동일 종목이 이미 감시 중이면 삭제 후 재등록 (중복 방지)
AutoTradeTable.deleteWhere { stockCode eq item.code }
AutoTradeTable.insert {
it[stockCode] = item.code
it[stockName] = item.name
it[targetPrice] = item.targetPrice
it[stopLossPrice] = item.stopLossPrice
it[status] = "MONITORING"
it[isDomestic] = item.isDomestic
}
}
} }
/**
* [추가] 매도 완료 또는 취소 감시 대상 삭제
*/
fun deleteAutoTrade(code: String) {
transaction {
AutoTradeTable.deleteWhere { stockCode eq code }
}
}
/**
* [수정] 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용)
*/
fun getActiveAutoTrades(): List<AutoTradeItem> = transaction {
AutoTradeTable.select { AutoTradeTable.status eq "MONITORING" }
.map { mapToAutoTradeItem(it) }
}
// ResultRow를 AutoTradeItem으로 매핑하는 내부 함수
private fun mapToAutoTradeItem(it: ResultRow) = AutoTradeItem( private fun mapToAutoTradeItem(it: ResultRow) = AutoTradeItem(
id = it[AutoTradeTable.id],
code = it[AutoTradeTable.stockCode], code = it[AutoTradeTable.stockCode],
name = it[AutoTradeTable.stockName], name = it[AutoTradeTable.stockName],
quantity = it[AutoTradeTable.quantity],
profitRate = it[AutoTradeTable.profitRate],
stopLossRate = it[AutoTradeTable.stopLossRate],
targetPrice = it[AutoTradeTable.targetPrice], targetPrice = it[AutoTradeTable.targetPrice],
stopLossPrice = it[AutoTradeTable.stopLossPrice], stopLossPrice = it[AutoTradeTable.stopLossPrice],
orderNo = it[AutoTradeTable.orderNo],
status = it[AutoTradeTable.status], status = it[AutoTradeTable.status],
isDomestic = it[AutoTradeTable.isDomestic] 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( data class AutoTradeItem(
val code: String, val id: Int? = null, // DB 식별자
val name: String, val orderNo: String, // 핵심 키: KIS 주문번호 (odno)
val targetPrice: Double, val code: String, // 종목 코드
val stopLossPrice: Double, // 손절가 추가 val name: String, // 종목 명
val status: String,
val isDomestic: Boolean // 상태 머신 (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 { object KisSession {
var config: AppConfig = AppConfig() var config: AppConfig = AppConfig()
fun getWebSocketKey() = config.websocketToken
// 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주) // 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주)
fun isMarketTokenValid(): Boolean { fun isMarketTokenValid(): Boolean {
return config.marketToken.isNotEmpty() && return config.marketToken.isNotEmpty() &&

View File

@ -1,5 +1,6 @@
package model package model
import AutoTradeItem
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -121,15 +122,14 @@ data class UnifiedBalance(
@Serializable @Serializable
data class UnfilledOrder( data class UnfilledOrder(
val ord_no: String, // 주문번호 val orgn_odno: String,
val orgn_ord_no: String, // 원주문번호 @SerialName("odno") val ord_no: String, // JSON의 odno를 ord_no로 매핑
val pdno: String, // 종목코드 val pdno: String,
val prdt_name: String, // 종목명 @SerialName("prdt_name") val prdt_name: String,
val ord_qty: String, // 주문수량 val ord_unpr: String, // JSON이 문자열이므로 String 권장
val ord_unpr: String, // 주문단가 @SerialName("psbl_qty") val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑
val rmnd_qty: String, // 체결 잔량 (미체결 수량) val ord_dvsn_name: String,
val ord_tmd: String, // 주문시각 val rvse_cncl_dvsn_name: String
val sll_buy_dvsn_cd: String // 매도매수구분 (01: 매도, 02: 매수)
) )
@Serializable @Serializable
@ -139,15 +139,23 @@ data class UnfilledResponse(
val output: List<UnfilledOrder> = emptyList() val output: List<UnfilledOrder> = emptyList()
) )
// src/main/kotlin/model/TradeModels.kt 내 추가 fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem {
enum class ActiveTradeType { MONITORING, UNFILLED } 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 code: String,
val name: String, val name: String,
val type: ActiveTradeType, val isDomestic: Boolean,
val price: Double, // 목표가 또는 주문단가 val quantity: String = "0"
val quantity: String, // 미체결 수량 (감시 중에는 "-")
val isDomestic: Boolean
) )

View File

@ -1,5 +1,6 @@
package network package network
import AutoTradeItem
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
@ -323,21 +324,45 @@ class KisTradeService {
val rtCd = body["rt_cd"]?.jsonPrimitive?.content val rtCd = body["rt_cd"]?.jsonPrimitive?.content
val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음" val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음"
if (rtCd == "0") Result.success("✅ 주문 성공: $msg") if (rtCd == "0") {
else Result.failure(Exception("❌ 오류 ($rtCd): $msg")) // 응답의 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) } } 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>> { suspend fun fetchUnfilledOrders(): Result<List<UnfilledOrder>> {
val config = KisSession.config 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 baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = "TTTC0084R" 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 { return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl") { val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl") {
header("authorization", "Bearer ${config.tradeToken}") header("authorization", "Bearer ${config.tradeToken}")
@ -346,8 +371,8 @@ class KisTradeService {
header("tr_id", trId) header("tr_id", trId)
header("custtype", "P") header("custtype", "P")
parameter("CANO", config.accountNo.take(8)) parameter("CANO", cano)
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2)) parameter("ACNT_PRDT_CD", acntPrdtCd)
parameter("CTX_AREA_FK100", "") parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "") parameter("CTX_AREA_NK100", "")
parameter("T_GUBUN", "0") parameter("T_GUBUN", "0")
@ -356,8 +381,16 @@ class KisTradeService {
parameter("INQR_DVSN_1", "0") parameter("INQR_DVSN_1", "0")
parameter("INQR_DVSN_2", "0") parameter("INQR_DVSN_2", "0")
} }
println("result >> ${response.status}")
val body = response.body<UnfilledResponse>() 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)) else Result.failure(Exception(body.msg1))
} catch (e: Exception) { Result.failure(e) } } catch (e: Exception) { Result.failure(e) }
} }
@ -369,8 +402,13 @@ class KisTradeService {
val config = KisSession.config val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC0803U" else "TTTC0803U" 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 { return try {
println("orgNo")
val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-rvsecncl") { val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-rvsecncl") {
header("authorization", "Bearer ${config.tradeToken}") header("authorization", "Bearer ${config.tradeToken}")
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
@ -379,14 +417,15 @@ class KisTradeService {
header("Content-Type", "application/json") header("Content-Type", "application/json")
setBody(mapOf( setBody(mapOf(
"CANO" to config.accountNo.take(8), "CANO" to cano,
"ACNT_PRDT_CD" to config.accountNo.takeLast(2), "ACNT_PRDT_CD" to acntPrdtCd,
"KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호 "KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호
"ORGN_ORD_NO" to orgNo, // 취소할 원주문번호 "ORGN_ODNO" to orgNo, // 취소할 원주문번호
"RVSE_CNCL_DVSN" to "02", // 01: 정정, 02: 취소 "RVSE_CNCL_DVSN_CD" to "02", // 01: 정정, 02: 취소
"ORD_DVSN" to "00", // 지정가 "ORD_DVSN" to "00", // 지정가
"ORD_QTY" to "0", // 0이면 전량 취소 "ORD_QTY" to "0", // 0이면 전량 취소
"ORD_UNPR" to "0" "ORD_UNPR" to "0",
"QTY_ALL_ORD_YN" to "Y",
)) ))
} }
val body = response.body<JsonObject>() val body = response.body<JsonObject>()
@ -472,14 +511,19 @@ class KisTradeService {
val config = KisSession.config val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R" 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 { return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer ${config.tradeToken}") header("authorization", "Bearer ${config.tradeToken}")
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey) header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
header("tr_id", trId) header("tr_id", trId)
parameter("CANO", config.accountNo.take(8)) parameter("CANO", cano)
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2)) parameter("ACNT_PRDT_CD", acntPrdtCd)
parameter("AFHR_FLPR_YN", "N") parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N") parameter("OFL_YN", "N")
parameter("INQR_DVSN", "02") parameter("INQR_DVSN", "02")

View File

@ -1,198 +1,127 @@
package network package network
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import io.ktor.client.* 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.client.plugins.websocket.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow
import model.KisSession import model.KisSession
import model.RealTimeTrade import java.util.concurrent.atomic.AtomicBoolean
import model.TradeType
class KisWebSocketManager { class KisWebSocketManager {
private val client = HttpClient(CIO) { private val client = HttpClient { install(WebSockets) }
install(WebSockets) { pingInterval = 20_000 }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000
}
}
private var session: DefaultClientWebSocketSession? = null 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") var onPriceUpdate: ((String, Double) -> Unit)? = null
val tradeLogs = mutableStateListOf<RealTimeTrade>() var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? = null
// 콜백: 체결 발생 시 (주문번호, 종목코드, 가격, 수량, 매수/매도여부)
var onExecutionReceived: ((orderNo: String, code: String, price: String, qty: String, isBuy: Boolean) -> Unit)? = null
// 콜백: 감시 조건 도달 시 (종목코드, 현재가, 타입)
var onTargetReached: ((code: String, price: Double, isProfit: Boolean) -> Unit)? = null
suspend fun connect() { suspend fun connect() {
val config = KisSession.config if (isConnected.get()) return
if (config.websocketToken.isEmpty()) return val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:21000" else "ops.koreainvestment.com:31000"
val hostUrl = "ops.koreainvestment.com"
val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름)
scope.launch { scope.launch {
try { 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 session = this
println("✅ 웹소켓 서버 연결 성공") isConnected.set(true)
println("✅ 웹소켓 연결 성공")
incoming.consumeAsFlow().collect { frame -> // 연결 직후 HTS ID 기반 체결 통보 자동 구독
if (frame is Frame.Text) { val htsId = KisSession.config.htsId
parseTradeData(frame.readText()) if (htsId.isNotEmpty()) sendRequest("1", "H0STT084R", htsId)
}
for (frame in incoming) {
if (frame is Frame.Text) handleMessage(frame.readText())
} }
} }
} catch (e: Exception) { } 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<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 trId = parts[1]
val body = parts[3] val dataRows = parts[3].split("^")
when (trId) { when (trId) {
"H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리 "H0STCNT0" -> {
"H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리 val price = dataRows[2]
} _currentPrice.value = price // 상태 업데이트
} onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0)
/** // 로그 추가 (예시)
* [1] 실시간 가격 데이터 처리 감시 로직 tradeLogs.add(0, model.RealTimeTrade(
*/ time = dataRows[1],
private fun handlePriceData(body: String) { price = price,
val rows = body.split("^") change = dataRows[4],
if (rows.size < 16) return volume = dataRows[2],
type = model.TradeType.NEUTRAL
val stockCode = rows[0] ))
val priceStr = rows[2] if (tradeLogs.size > 50) tradeLogs.removeLast()
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) }
} }
"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() { fun clearData() {
tradeLogs.clear() 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<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)
}
} }

View File

@ -1,6 +1,7 @@
// src/main/kotlin/ui/ActiveTradeRow.kt // src/main/kotlin/ui/ActiveTradeRow.kt
package ui package ui
import AutoTradeItem
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import model.ActiveTradeItem
import model.ActiveTradeType
@Composable @Composable
fun ActiveTradeRow( fun ActiveTradeRow(
item: ActiveTradeItem, item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
onCancelClick: (String) -> Unit = {}, // 미체결 취소용 onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
onClick: () -> Unit onClick: () -> Unit
) { ) {
val isMonitoring = item.type == ActiveTradeType.MONITORING // 상태에 따른 UI 구성 요소 정의
val (statusText, statusColor, backgroundColor) = when (item.status) {
// 상태에 따른 배경색 설정 (미체결은 연노랑으로 강조) "PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑
val backgroundColor = if (isMonitoring) Color.White else Color(0xFFFFF9C4) "MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑
val badgeColor = if (isMonitoring) Color(0xFF0E62CF) else Color(0xFFE03E2D) "SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강
"COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색
else -> Triple("알 수 없음", Color.Black, Color.White)
}
Card( Card(
modifier = Modifier modifier = Modifier
@ -39,65 +41,67 @@ fun ActiveTradeRow(
) { ) {
Row( Row(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
// 좌측 정보 영역
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// 상태 배지 (자동감시 / 미체결) // 상태 배지 표시
Surface( Surface(
color = badgeColor, color = statusColor,
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(2.dp),
modifier = Modifier.padding(end = 6.dp)
) { ) {
Text( Text(
text = if (isMonitoring) "자동감시" else "미체결", text = statusText,
color = Color.White, color = Color.White,
fontSize = 10.sp, fontSize = 9.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontWeight = FontWeight.Bold
) )
} }
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = item.name, text = item.name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 14.sp,
maxLines = 1 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(
text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}", text = "${item.code} | $detailText",
fontSize = 11.sp, fontSize = 11.sp,
color = Color.Gray color = Color.Gray
) )
} }
// 우측 액션 영역 // 우측 액션 및 수량 영역
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
if (!isMonitoring) { if (item.status == "PENDING_BUY" || item.status == "SELLING") {
// 미체결인 경우 취소 버튼 표시 // 진행 중인 주문인 경우 취소 버튼 노출
Button( Button(
onClick = { onCancelClick(item.id) }, onClick = { onCancelClick() },
contentPadding = PaddingValues(horizontal = 8.dp), contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.height(28.dp), modifier = Modifier.height(28.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
) { ) {
Text("취소", fontSize = 11.sp) 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
)
} }
} }
} }

View File

@ -15,51 +15,35 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import model.ActiveTradeItem import model.toAutoTradeItem
import model.ActiveTradeType
import network.KisTradeService import network.KisTradeService
// src/main/kotlin/ui/AutoTradeSection.kt // src/main/kotlin/ui/AutoTradeSection.kt
@Composable @Composable
fun AutoTradeSection( fun AutoTradeSection(
isDomestic: Boolean,
tradeService: KisTradeService, tradeService: KisTradeService,
refreshTrigger: Int, // 갱신 트리거 추가 refreshTrigger: Int, // 갱신 트리거 추가
onRefresh: () -> Unit, onRefresh: () -> Unit,
onItemSelect: (ActiveTradeItem) -> Unit onItemSelect: (AutoTradeItem) -> Unit,
onItemCancel: (AutoTradeItem) -> Unit
) { ) {
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델) // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
var combinedList by remember { mutableStateOf(emptyList<ActiveTradeItem>()) } var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
// refreshTrigger가 바뀔 때마다 실행됨 // refreshTrigger가 바뀔 때마다 실행됨
LaunchedEffect(refreshTrigger) { LaunchedEffect(refreshTrigger) {
// 1. DB에서 감시 중인 종목 로드 val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
val monitoringItems = DatabaseFactory.getActiveAutoTrades().map {
ActiveTradeItem(
id = it.code,
code = it.code,
name = it.name,
type = ActiveTradeType.MONITORING,
price = it.targetPrice,
quantity = "-",
isDomestic = it.isDomestic
)
}
// 2. KIS API에서 미체결 주문 로드 // 2. DB에서 로컬 감시 데이터 가져오기
val unfilledItems = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()).map { val localTrades = DatabaseFactory.getActiveAutoTrades()
ActiveTradeItem(
id = it.ord_no,
code = it.pdno,
name = it.prdt_name,
type = ActiveTradeType.UNFILLED,
price = it.ord_unpr.toDouble(),
quantity = it.rmnd_qty,
isDomestic = true
)
}
combinedList = monitoringItems + unfilledItems // 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)) { Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
@ -84,15 +68,11 @@ fun AutoTradeSection(
} }
} }
LazyColumn { LazyColumn {
items(combinedList) { item -> items(tradeList) { item ->
ActiveTradeRow( ActiveTradeRow(
item = item, item = item,
onCancelClick = { orderNo -> onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
// tradeService.cancelOrder(orderNo, item.code) 호출 로직 onClick = { onItemSelect(item) }
},
onClick = {
onItemSelect(item) // 상세 화면 전환용 콜백
}
) )
} }
} }

View File

@ -1,80 +1,116 @@
package ui //package ui
//
import androidx.compose.foundation.background //import AutoTradeItem
import androidx.compose.foundation.clickable //import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* //import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn //import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 //import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape //import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.material.* //import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* //import androidx.compose.material.*
import io.ktor.client.engine.cio.CIO //import androidx.compose.runtime.*
// 아래 두 import가 'delegate' 에러를 해결합니다. //import io.ktor.client.engine.cio.CIO
import androidx.compose.runtime.getValue //// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.setValue //import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment //import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier //import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color //import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight //import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign //import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow //import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp //import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp //import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay //import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch //import kotlinx.coroutines.delay
import model.AppConfig //import kotlinx.coroutines.launch
import model.BalanceSummary //import model.AppConfig
import model.CandleData //import model.BalanceSummary
import model.RankingStock //import model.CandleData
import model.StockHolding //import model.RankingStock
import network.KisTradeService //import model.StockHolding
import network.KisWebSocketManager //import network.KisTradeService
import kotlin.collections.isNotEmpty //import network.KisWebSocketManager
//import kotlin.collections.isNotEmpty
//
@Composable //
fun AutoTradeSettingCard(stockCode: String, currentPrice: String) { //@Composable
var profitRate by remember { mutableStateOf("5.0") } //fun AutoTradeSettingCard(
var stopLossRate by remember { mutableStateOf("-3.0") } // stockCode: String,
var isEnabled by remember { mutableStateOf(false) } // stockName: String, // 종목명 추가
// currentPrice: String,
Card( // isDomestic: Boolean = true
elevation = 4.dp, //) {
shape = RoundedCornerShape(8.dp), // var profitRate by remember { mutableStateOf("5.0") }
backgroundColor = Color(0xFFF8F9FA) // detail.html의 order-box 배경색 참고 // var stopLossRate by remember { mutableStateOf("-3.0") }
) { //
Column(modifier = Modifier.padding(12.dp)) { // // DB에서 현재 감시 중인지 확인
Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2) // var isEnabled by remember(stockCode) {
Spacer(modifier = Modifier.height(8.dp)) // mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null)
// }
Row(verticalAlignment = Alignment.CenterVertically) { //
OutlinedTextField( // Card(
value = profitRate, // elevation = 4.dp,
onValueChange = { profitRate = it }, // shape = RoundedCornerShape(8.dp),
label = { Text("익절 %") }, // backgroundColor = Color(0xFFF8F9FA)
modifier = Modifier.weight(1f).padding(end = 4.dp), // ) {
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp) // Column(modifier = Modifier.padding(12.dp)) {
) // Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
OutlinedTextField( // Spacer(modifier = Modifier.height(8.dp))
value = stopLossRate, //
onValueChange = { stopLossRate = it }, // Row(verticalAlignment = Alignment.CenterVertically) {
label = { Text("손절 %") }, // OutlinedTextField(
modifier = Modifier.weight(1f), // value = profitRate,
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp) // onValueChange = { profitRate = it },
) // label = { Text("익절 %") },
} // modifier = Modifier.weight(1f).padding(end = 4.dp),
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
Spacer(modifier = Modifier.height(8.dp)) // )
// OutlinedTextField(
Button( // value = stopLossRate,
onClick = { isEnabled = !isEnabled }, // onValueChange = { stopLossRate = it },
modifier = Modifier.fillMaxWidth(), // label = { Text("손절 %") },
colors = ButtonDefaults.buttonColors( // modifier = Modifier.weight(1f),
backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF) // textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
) // )
) { // }
Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White) //
} // 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -17,13 +20,15 @@ import network.KisTradeService
@Composable @Composable
fun BalanceSection( fun BalanceSection(
tradeService: KisTradeService, 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 balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
// 화면 진입 시 및 갱신 시 데이터 로드 // 화면 진입 시 및 갱신 시 데이터 로드
LaunchedEffect(Unit) { LaunchedEffect(refreshTrigger) {
isLoading = true isLoading = true
tradeService.fetchIntegratedBalance().onSuccess { tradeService.fetchIntegratedBalance().onSuccess {
balanceData = it balanceData = it
@ -34,13 +39,30 @@ fun BalanceSection(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Text( Row(
text = "나의 자산", modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.h6, horizontalArrangement = Arrangement.SpaceBetween,
fontWeight = FontWeight.Bold, verticalAlignment = Alignment.CenterVertically
modifier = Modifier.padding(bottom = 8.dp) ) {
) 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. 자산 요약 카드 // 1. 자산 요약 카드
BalanceSummaryCard(balanceData) BalanceSummaryCard(balanceData)
@ -62,7 +84,7 @@ fun BalanceSection(
LazyColumn(modifier = Modifier.weight(1f, true)) { LazyColumn(modifier = Modifier.weight(1f, true)) {
items(balanceData?.holdings ?: emptyList()) { holding -> items(balanceData?.holdings ?: emptyList()) { holding ->
UnifiedStockItemRow(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(), modifier = Modifier.fillMaxWidth(),
backgroundColor = androidx.compose.ui.graphics.Color.White backgroundColor = androidx.compose.ui.graphics.Color.White
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray) Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
Text( Text(
@ -103,6 +126,9 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
@Composable @Composable
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) { 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( Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
elevation = 1.dp elevation = 1.dp
@ -126,19 +152,27 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1) 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) Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
} }
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text("${holding.currentPrice}") Text("${holding.currentPrice}", fontWeight = FontWeight.Bold)
// 손익분기점 표시 추가
Text(
"손익분기: ${String.format("%,.0f", breakEvenPrice)}",
fontSize = 10.sp, color = Color(0xFF666666)
)
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0 val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
Text( Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%", text = "${if (rate > 0) "+" else ""}${holding.profitRate}%",
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue fontSize = 12.sp,
else androidx.compose.ui.graphics.Color.DarkGray, fontWeight = FontWeight.Bold
fontSize = 12.sp
) )
} }
} }

View File

@ -12,87 +12,80 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.KisSession import model.KisSession
import model.StockBasicInfo
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import util.MarketUtil
@Composable @Composable
fun DashboardScreen() { fun DashboardScreen() {
val tradeService = remember { KisTradeService() } val tradeService = remember { KisTradeService() }
val wsManager = remember { KisWebSocketManager() } val wsManager = remember { KisWebSocketManager() }
val config = KisSession.config
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// 데이터 갱신을 위한 트리거 상태
var refreshTrigger by remember { mutableStateOf(0) } var refreshTrigger by remember { mutableStateOf(0) }
// 전역 상태: 현재 선택된 종목 정보
var selectedStockCode by remember { mutableStateOf("") } var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") }
var isDomestic by remember { mutableStateOf(true) } 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) { LaunchedEffect(Unit) {
// 1. 웹소켓 연결 // 1. 웹소켓 연결
wsManager.connect() wsManager.connect()
// 2. 체결 통보 콜백 설정 (매수 성공 시 감시 시작) // 2. [기동 시 동기화 시나리오]
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy -> scope.launch {
if (isBuy) { // (1) 서버 미체결 내역 로드
// [매수 체결 시] DB에 감시 데이터 저장 val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
// 주의: targetPrice와 stopLossPrice는 이전에 설정된 값을 가져오거나 val serverOrderNos = serverOrders.map { it.ord_no }
// 임시 상태값에서 가져와야 함 (여기선 예시로 현재가의 +5%, -3% 설정)
val execPrice = price.toDoubleOrNull() ?: 0.0 // (2) DB 상태 대조 및 EXPIRED 전환
DatabaseFactory.saveAutoTrade( DatabaseFactory.syncWithServer(serverOrderNos)
AutoTradeItem(
code = code, // (3) 활성 감시 종목 구독 재개
name = "", // 필요 시 종목명 매핑 val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
targetPrice = execPrice * 1.05, val monitoringCodes = monitoringTrades.map { it.code }.toSet()
stopLossPrice = execPrice * 0.97, wsManager.updateSubscriptions(monitoringCodes)
status = "MONITORING",
isDomestic = true
)
)
println("📝 매수 체결로 인한 자동 감시 등록: $code")
} else {
// [매도 체결 시] 감시 종료 및 DB 삭제
DatabaseFactory.deleteAutoTrade(code)
println("✅ 매도 체결로 인한 감시 종료: $code")
}
refreshTrigger++ refreshTrigger++
} }
// 3. 목표가 도달 콜백 설정 (자동 매도 실행) // 3. 실시간 체결 통보 핸들러 (주문번호 중심)
wsManager.onTargetReached = { code, price, isProfit -> wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
scope.launch { scope.launch {
println("🚀 목표가 도달! 자동 매도 주문 실행: $code (이유: ${if(isProfit) "익절" else "손절"})") val dbItem = DatabaseFactory.findByOrderNo(orderNo)
if (dbItem != null) {
// 실제 매도 주문 API 호출 when (dbItem.status) {
tradeService.postOrder( TradeStatus.PENDING_BUY -> {
stockCode = code, // 매수 주문 체결 -> 감시(MONITORING)로 전환 및 익절 주문 발주
qty = "1", // 실제론 보유 수량을 가져와야 함 // 여기서 익절 주문 후 받은 신규 주문번호를 DB에 갱신
price = "0", // 시장가 매도 // updateStatusAndOrderNo(dbItem.id!!, TradeStatus.MONITORING, newSellOrderNo)
isBuy = false }
).onSuccess { TradeStatus.SELLING -> {
// 매도 주문 성공 시 로그 기록 // 매도(손절/익절) 주문 체결 -> COMPLETED
DatabaseFactory.saveTradeLog( DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
code, "", "매도", price, 1, }
if(isProfit) "AI 익절 조건 달성" else "AI 손절 조건 달성" }
) refreshTrigger++
} }
} }
refreshTrigger++
}
if (config.htsId.isNotEmpty()) {
wsManager.subscribeExecution(config.htsId)
println("📡 HTS ID(${config.htsId})로 체결 통보 구독을 시작합니다.")
} }
} }
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 내 자산 및 통합 잔고 // [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { 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 selectedStockCode = code
selectedStockName = name selectedStockName = name
isDomestic = isDom isDomestic = isDom
selectedStockQuantity = qty
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic") println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
} }
} }
@ -105,6 +98,7 @@ fun DashboardScreen() {
StockDetailSection( StockDetailSection(
stockCode = selectedStockCode, stockCode = selectedStockCode,
stockName = selectedStockName, stockName = selectedStockName,
holdingQuantity = selectedStockQuantity,
isDomestic = isDomestic, isDomestic = isDomestic,
tradeService = tradeService, tradeService = tradeService,
wsManager = wsManager wsManager = wsManager
@ -119,19 +113,33 @@ fun DashboardScreen() {
VerticalDivider() VerticalDivider()
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
AutoTradeSection( AutoTradeSection(
isDomestic = isDomestic,
tradeService = tradeService, tradeService = tradeService,
onRefresh = { refreshTrigger++ }, onRefresh = { refreshTrigger++ },
refreshTrigger = refreshTrigger // 트리거 전달 refreshTrigger = refreshTrigger , // 트리거 전달
) { item -> onItemCancel = { item ->
selectedStockCode = item.code scope.launch {
selectedStockName = item.name tradeService.cancelOrder(item.orderNo,item.code).onSuccess {
isDomestic = item.isDomestic refreshTrigger++
} }
}
},
onItemSelect = { item ->
selectedStockCode = item.code
selectedStockName = item.name
isDomestic = item.isDomestic
})
} }
VerticalDivider() VerticalDivider()
// [우측 30%] 시장 추천 TOP 20 (실전 데이터) // [우측 30%] 시장 추천 TOP 20 (실전 데이터)
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) { Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
MarketSection(tradeService) { code, name, isDom -> MarketSection(tradeService) { code, name, isDom ->
val info = StockBasicInfo(
code = code,
name = name,
isDomestic = isDom
)
selectedStockInfo = info
selectedStockCode = code selectedStockCode = code
selectedStockName = name selectedStockName = name
isDomestic = isDom isDomestic = isDom

View File

@ -1,6 +1,7 @@
// src/main/kotlin/ui/IntegratedOrderSection.kt // src/main/kotlin/ui/IntegratedOrderSection.kt
package ui package ui
import AutoTradeItem
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -15,29 +16,56 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.KisTradeService import network.KisTradeService
/**
* 통합 주문 자동매매 설정 섹션
* * [수정 사항]
* 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지
* 2. 수량 입력 콤마 제거 Int 변환 예외 처리 적용
* 3. 매수 성공 반환받은 실제 주문번호(ODNO) DB에 저장하여 주문번호 중심 관리 구현
*/
@Composable @Composable
fun IntegratedOrderSection( fun IntegratedOrderSection(
stockCode: String, stockCode: String,
stockName: String,
isDomestic: Boolean,
currentPrice: String, currentPrice: String,
holdingQuantity: String,
tradeService: KisTradeService, tradeService: KisTradeService,
onOrderResult: (String, Boolean) -> Unit onOrderResult: (String, Boolean) -> Unit
) { ) {
val scope = rememberCoroutineScope() 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 orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
var orderQty by remember(holdingQuantity) {
// 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리)
val cleanQty = holdingQuantity.replace(",", "")
mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty)
}
// 자동 매도 설정 var profitRate by remember(monitoringItem) {
var isAutoSellEnabled by remember { mutableStateOf(false) } mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0")
var profitRate by remember { mutableStateOf("5.0") } }
var stopLossRate by remember { mutableStateOf("-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)) { Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// 1. 가격 및 수량 입력 // 가격 및 수량 입력 필드
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
OutlinedTextField( OutlinedTextField(
value = orderQty, value = orderQty,
@ -54,71 +82,121 @@ fun IntegratedOrderSection(
) )
} }
// 2. 수익률 시뮬레이션 표 (신규 추가) // 수익률 시뮬레이션 표
if (basePrice > 0 && qty > 0) { if (basePrice > 0 && inputQty > 0) {
Text("익절/손절 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 11.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 4.dp)) SimulationCard(basePrice, inputQty.toDouble())
Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) {
Column(modifier = Modifier.padding(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%"), true)
SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }, false)
SimulationColumn("예상수령액", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate ->
val sellPrice = basePrice * rate
val totalAmount = sellPrice * qty
val netAmount = totalAmount * (1 - 0.0022) // 수수료+세금 약 0.22% 차감
String.format("%,d", netAmount.toLong())
}, false)
}
}
}
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// 3. 자동 매도 옵션 // 실시간 AI 매도 감시 설정 카드
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) { Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
Column(modifier = Modifier.padding(8.dp)) { Column(modifier = Modifier.padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isAutoSellEnabled, onCheckedChange = { isAutoSellEnabled = it }) Checkbox(
Text("매수 체결 시 자동 매도 감시 시작", fontSize = 12.sp) 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 { Row {
OutlinedTextField( OutlinedTextField(
value = profitRate, onValueChange = { profitRate = it }, 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 }, OutlinedTextField(
label = { Text("손절 %") }, modifier = Modifier.weight(1f) value = stopLossRate, onValueChange = { stopLossRate = it },
) label = { Text("손절 %") }, modifier = Modifier.weight(1f),
} enabled = !isAutoSellEnabled
)
} }
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// 4. 매수/매도 버튼 // 매수 / 매도 실행 버튼
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
// 매수 버튼
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult(it, true) onOrderResult("주문 성공: $realOrderNo", true)
if (isAutoSellEnabled) { /* 자동매도 등록 로직 호출 */ } 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), modifier = Modifier.weight(1f).padding(end = 4.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)) colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
) { Text("매수", color = Color.White) } ) { Text("매수", color = Color.White) }
// 매도 버튼
Button( 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), modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)) colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
) { Text("매도", color = Color.White) } ) { Text("매도", color = Color.White) }
@ -127,16 +205,33 @@ fun IntegratedOrderSection(
} }
@Composable @Composable
fun SimulationColumn(title: String, items: List<String>, 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<String>) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray) Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray)
items.forEach { text -> items.forEach { text ->
Text( val color = when {
text = text, text.contains("+") -> Color(0xFFE03E2D)
fontSize = 11.sp, text.contains("-") -> Color(0xFF0E62CF)
color = if (text.contains("+")) Color(0xFFE03E2D) else if (text.contains("-")) Color(0xFF0E62CF) else Color.Black, else -> Color.Black
modifier = Modifier.padding(vertical = 1.dp) }
) 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( fun StockDetailSection(
stockCode: String, stockCode: String,
stockName: String, stockName: String,
holdingQuantity : String,
isDomestic: Boolean, isDomestic: Boolean,
tradeService: KisTradeService, tradeService: KisTradeService,
wsManager: KisWebSocketManager wsManager: KisWebSocketManager
@ -153,6 +154,7 @@ fun StockDetailSection(
previousClose = previousClose, previousClose = previousClose,
openPrice = openPrice, openPrice = openPrice,
resultMessage = resultMessage, resultMessage = resultMessage,
resultMessageClear = {resultMessage = ""},
isSuccess = isSuccess isSuccess = isSuccess
) )
@ -213,7 +215,10 @@ fun StockDetailSection(
Column(modifier = Modifier.weight(0.6f)) { Column(modifier = Modifier.weight(0.6f)) {
IntegratedOrderSection( IntegratedOrderSection(
stockCode = stockCode, stockCode = stockCode,
stockName = stockName,
isDomestic = isDomestic,
currentPrice = wsManager.currentPrice.value, currentPrice = wsManager.currentPrice.value,
holdingQuantity = holdingQuantity,
tradeService = tradeService, tradeService = tradeService,
onOrderResult = { msg, success -> onOrderResult = { msg, success ->
resultMessage = msg 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun StockHeader( fun StockHeader(
name: String, name: String,
@ -21,6 +22,7 @@ fun StockHeader(
previousClose: String, // 추가: 전일 종가 previousClose: String, // 추가: 전일 종가
openPrice: String, // 추가: 금일 시가 openPrice: String, // 추가: 금일 시가
resultMessage: String, resultMessage: String,
resultMessageClear : ()->Unit,
isSuccess: Boolean isSuccess: Boolean
) { ) {
Column(modifier = Modifier.wrapContentWidth()) { Column(modifier = Modifier.wrapContentWidth()) {
@ -28,8 +30,11 @@ fun StockHeader(
if (resultMessage.isNotEmpty()) { if (resultMessage.isNotEmpty()) {
Surface( Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp),
onClick = {
resultMessageClear.invoke()
}
) { ) {
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold) 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) 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
}
} }