...
This commit is contained in:
parent
bdc268e325
commit
91f9e4ee9a
@ -44,6 +44,8 @@ fun main() = application {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
@ -69,7 +71,6 @@ fun main() = application {
|
||||
)
|
||||
}
|
||||
AppScreen.Dashboard -> {
|
||||
// 이제 모든 서비스는 KisSession.config를 전역 참조함
|
||||
DashboardScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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() &&
|
||||
|
||||
@ -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"
|
||||
)
|
||||
@ -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")
|
||||
|
||||
@ -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("|")
|
||||
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 body = parts[3]
|
||||
val dataRows = parts[3].split("^")
|
||||
|
||||
when (trId) {
|
||||
"H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리
|
||||
"H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리
|
||||
}
|
||||
}
|
||||
"H0STCNT0" -> {
|
||||
val price = dataRows[2]
|
||||
_currentPrice.value = price // 상태 업데이트
|
||||
onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0)
|
||||
|
||||
/**
|
||||
* [1] 실시간 가격 데이터 처리 및 감시 로직
|
||||
*/
|
||||
private fun handlePriceData(body: String) {
|
||||
val rows = body.split("^")
|
||||
if (rows.size < 16) return
|
||||
|
||||
val stockCode = rows[0]
|
||||
val priceStr = rows[2]
|
||||
val currentPriceInt = priceStr.toIntOrNull() ?: 0
|
||||
|
||||
val newTrade = RealTimeTrade(
|
||||
time = rows[1].chunked(2).joinToString(":"),
|
||||
price = priceStr,
|
||||
change = rows[4],
|
||||
volume = rows[12],
|
||||
type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL
|
||||
)
|
||||
|
||||
scope.launch(Dispatchers.Main) {
|
||||
tradeLogs.add(0, newTrade)
|
||||
if (tradeLogs.size > 30) tradeLogs.removeLast()
|
||||
currentPrice.value = String.format("%,d", currentPriceInt)
|
||||
|
||||
// 실시간 감시 엔진 작동
|
||||
checkAutoTradeTargets(stockCode, currentPriceInt.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [2] 실시간 개인 체결 통보 처리
|
||||
*/
|
||||
private fun handleExecutionData(body: String) {
|
||||
val rows = body.split("^")
|
||||
if (rows.size < 13) return
|
||||
|
||||
val orderNo = rows[1]
|
||||
val stockCode = rows[7]
|
||||
val side = rows[9] // 01: 매도, 02: 매수
|
||||
val price = rows[11]
|
||||
val qty = rows[12]
|
||||
|
||||
scope.launch(Dispatchers.Main) {
|
||||
val isBuy = side == "02"
|
||||
println("📣 체결 통보 수신: $stockCode | ${if(isBuy) "매수" else "매도"} | $price 원")
|
||||
|
||||
// 외부 콜백 실행 (DB 업데이트 및 UI 전환 트리거)
|
||||
onExecutionReceived?.invoke(orderNo, stockCode, price, qty, isBuy)
|
||||
|
||||
// 매수 체결 시 즉시 해당 종목 실시간 시세 구독 시작
|
||||
if (isBuy) subscribeStock(stockCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동매매 목표가 도달 여부 판단
|
||||
*/
|
||||
private fun checkAutoTradeTargets(code: String, currentPrice: Double) {
|
||||
// DB에서 해당 종목의 감시 설정(익절/손절가)을 가져와 비교
|
||||
// 효율성을 위해 Map 등에 캐싱하여 사용할 것을 권장
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val config = DatabaseFactory.findConfigByCode(code) ?: return@launch
|
||||
|
||||
if (currentPrice >= config.targetPrice) {
|
||||
withContext(Dispatchers.Main) { onTargetReached?.invoke(code, currentPrice, true) }
|
||||
} else if (currentPrice <= config.stopLossPrice) {
|
||||
withContext(Dispatchers.Main) { onTargetReached?.invoke(code, currentPrice, false) }
|
||||
// 로그 추가 (예시)
|
||||
tradeLogs.add(0, model.RealTimeTrade(
|
||||
time = dataRows[1],
|
||||
price = price,
|
||||
change = dataRows[4],
|
||||
volume = dataRows[2],
|
||||
type = model.TradeType.NEUTRAL
|
||||
))
|
||||
if (tradeLogs.size > 50) tradeLogs.removeLast()
|
||||
}
|
||||
"H0STT084R" -> onExecutionReceived?.invoke(dataRows[5], dataRows[9], dataRows[12], dataRows[13], dataRows[15] == "02")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개인 체결 통보 구독 (HTS ID 필요)
|
||||
*/
|
||||
suspend fun subscribeExecution(htsId: String) {
|
||||
sendRequest(htsId, trType = "1", trId = "H0STCNI0")
|
||||
println("📡 실시간 체결 통보 구독 시작: $htsId")
|
||||
}
|
||||
|
||||
suspend fun subscribeStock(stockCode: String) {
|
||||
sendRequest(stockCode, trType = "1", trId = "H0STCNT0")
|
||||
}
|
||||
|
||||
suspend fun unsubscribeStock(stockCode: String) {
|
||||
if (stockCode.isNotEmpty()) sendRequest(stockCode, trType = "2", trId = "H0STCNT0")
|
||||
}
|
||||
|
||||
private suspend fun sendRequest(key: String, trType: String, trId: String) {
|
||||
val currentSession = session ?: return
|
||||
val config = KisSession.config
|
||||
|
||||
val requestJson = """
|
||||
{
|
||||
"header": {
|
||||
"approval_key": "${config.websocketToken}",
|
||||
"custtype": "P",
|
||||
"tr_type": "$trType",
|
||||
"content-type": "utf-8"
|
||||
},
|
||||
"body": {
|
||||
"input": {
|
||||
"tr_id": "$trId",
|
||||
"tr_key": "$key"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
currentSession.send(Frame.Text(requestJson))
|
||||
} catch (e: Exception) {
|
||||
println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
tradeLogs.clear()
|
||||
currentPrice.value = "0"
|
||||
_currentPrice.value = "0"
|
||||
}
|
||||
|
||||
suspend fun subscribeStock(code: String, isSubscribe: Boolean = true) {
|
||||
val trType = if (isSubscribe) "1" else "2"
|
||||
sendRequest(trType, "H0STCNT0", code)
|
||||
if (isSubscribe) println("📡 구독 등록: $code") else println("📴 구독 해제: $code")
|
||||
}
|
||||
|
||||
private suspend fun sendRequest(trType: String, trId: String, trKey: String) {
|
||||
val approvalKey = KisSession.getWebSocketKey() ?: return
|
||||
val json = """
|
||||
{
|
||||
"header": {
|
||||
"approval_key": "$approvalKey",
|
||||
"custtype": "P",
|
||||
"tr_type": "$trType",
|
||||
"content-type": "utf-8"
|
||||
},
|
||||
"body": {
|
||||
"input": { "tr_id": "$trId", "tr_key": "$trKey" }
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
session?.send(json)
|
||||
}
|
||||
private val activeSubscriptions = mutableSetOf<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)
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
// src/main/kotlin/ui/ActiveTradeRow.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@ -13,20 +14,21 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
|
||||
@Composable
|
||||
fun ActiveTradeRow(
|
||||
item: ActiveTradeItem,
|
||||
onCancelClick: (String) -> Unit = {}, // 미체결 취소용
|
||||
item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
|
||||
onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isMonitoring = item.type == ActiveTradeType.MONITORING
|
||||
|
||||
// 상태에 따른 배경색 설정 (미체결은 연노랑으로 강조)
|
||||
val backgroundColor = if (isMonitoring) Color.White else Color(0xFFFFF9C4)
|
||||
val badgeColor = if (isMonitoring) Color(0xFF0E62CF) else Color(0xFFE03E2D)
|
||||
// 상태에 따른 UI 구성 요소 정의
|
||||
val (statusText, statusColor, backgroundColor) = when (item.status) {
|
||||
"PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑
|
||||
"MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑
|
||||
"SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강
|
||||
"COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색
|
||||
else -> Triple("알 수 없음", Color.Black, Color.White)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@ -39,65 +41,67 @@ fun ActiveTradeRow(
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 좌측 정보 영역
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 상태 배지 (자동감시 / 미체결)
|
||||
// 상태 배지 표시
|
||||
Surface(
|
||||
color = badgeColor,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
color = statusColor,
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isMonitoring) "자동감시" else "미체결",
|
||||
text = statusText,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
// 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시)
|
||||
val detailText = when (item.status) {
|
||||
"PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
|
||||
"MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
|
||||
else -> "주문번호: ${item.orderNo}"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}",
|
||||
text = "${item.code} | $detailText",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 우측 액션 영역
|
||||
// 우측 액션 및 수량 영역
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
if (!isMonitoring) {
|
||||
// 미체결인 경우 취소 버튼 표시
|
||||
if (item.status == "PENDING_BUY" || item.status == "SELLING") {
|
||||
// 진행 중인 주문인 경우 취소 버튼 노출
|
||||
Button(
|
||||
onClick = { onCancelClick(item.id) },
|
||||
onClick = { onCancelClick() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier = Modifier.height(28.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
) {
|
||||
Text("취소", fontSize = 11.sp)
|
||||
}
|
||||
Text(
|
||||
text = "${item.quantity}주",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFE03E2D)
|
||||
)
|
||||
} else {
|
||||
// 자동감시 중인 경우 상태 텍스트 표시
|
||||
Text(
|
||||
text = "감시중",
|
||||
fontSize = 12.sp,
|
||||
color = badgeColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${item.quantity}주",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -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()) {
|
||||
Text(
|
||||
text = "나의 자산",
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "나의 자산",
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
// 강제 갱신 버튼
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||
contentDescription = "새로고침",
|
||||
tint = Color(0xFF0E62CF),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 1. 자산 요약 카드
|
||||
BalanceSummaryCard(balanceData)
|
||||
|
||||
@ -62,7 +84,7 @@ fun BalanceSection(
|
||||
LazyColumn(modifier = Modifier.weight(1f, true)) {
|
||||
items(balanceData?.holdings ?: emptyList()) { holding ->
|
||||
UnifiedStockItemRow(holding) {
|
||||
onStockSelect(holding.code, holding.name, holding.isDomestic)
|
||||
onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,6 +100,7 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
backgroundColor = androidx.compose.ui.graphics.Color.White
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
Text(
|
||||
@ -103,6 +126,9 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
|
||||
@Composable
|
||||
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
|
||||
val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0
|
||||
val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
elevation = 1.dp
|
||||
@ -126,19 +152,27 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
Text(
|
||||
"매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}",
|
||||
fontSize = 11.sp, color = Color.Gray
|
||||
)
|
||||
}
|
||||
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
|
||||
Text("${holding.currentPrice} 원")
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold)
|
||||
// 손익분기점 표시 추가
|
||||
Text(
|
||||
"손익분기: ${String.format("%,.0f", breakEvenPrice)}원",
|
||||
fontSize = 10.sp, color = Color(0xFF666666)
|
||||
)
|
||||
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
|
||||
Text(
|
||||
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%",
|
||||
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
|
||||
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color.DarkGray,
|
||||
fontSize = 12.sp
|
||||
text = "${if (rate > 0) "+" else ""}${holding.profitRate}%",
|
||||
color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
// 2. [기동 시 동기화 시나리오]
|
||||
scope.launch {
|
||||
// (1) 서버 미체결 내역 로드
|
||||
val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
|
||||
val serverOrderNos = serverOrders.map { it.ord_no }
|
||||
|
||||
// (2) DB 상태 대조 및 EXPIRED 전환
|
||||
DatabaseFactory.syncWithServer(serverOrderNos)
|
||||
|
||||
// (3) 활성 감시 종목 구독 재개
|
||||
val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
|
||||
val monitoringCodes = monitoringTrades.map { it.code }.toSet()
|
||||
wsManager.updateSubscriptions(monitoringCodes)
|
||||
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
// 3. 목표가 도달 콜백 설정 (자동 매도 실행)
|
||||
wsManager.onTargetReached = { code, price, isProfit ->
|
||||
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
||||
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
|
||||
scope.launch {
|
||||
println("🚀 목표가 도달! 자동 매도 주문 실행: $code (이유: ${if(isProfit) "익절" else "손절"})")
|
||||
|
||||
// 실제 매도 주문 API 호출
|
||||
tradeService.postOrder(
|
||||
stockCode = code,
|
||||
qty = "1", // 실제론 보유 수량을 가져와야 함
|
||||
price = "0", // 시장가 매도
|
||||
isBuy = false
|
||||
).onSuccess {
|
||||
// 매도 주문 성공 시 로그 기록
|
||||
DatabaseFactory.saveTradeLog(
|
||||
code, "", "매도", price, 1,
|
||||
if(isProfit) "AI 익절 조건 달성" else "AI 손절 조건 달성"
|
||||
)
|
||||
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||
if (dbItem != null) {
|
||||
when (dbItem.status) {
|
||||
TradeStatus.PENDING_BUY -> {
|
||||
// 매수 주문 체결 -> 감시(MONITORING)로 전환 및 익절 주문 발주
|
||||
// 여기서 익절 주문 후 받은 신규 주문번호를 DB에 갱신
|
||||
// updateStatusAndOrderNo(dbItem.id!!, TradeStatus.MONITORING, newSellOrderNo)
|
||||
}
|
||||
TradeStatus.SELLING -> {
|
||||
// 매도(손절/익절) 주문 체결 -> COMPLETED
|
||||
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||
}
|
||||
}
|
||||
refreshTrigger++
|
||||
}
|
||||
}
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
if (config.htsId.isNotEmpty()) {
|
||||
wsManager.subscribeExecution(config.htsId)
|
||||
println("📡 HTS ID(${config.htsId})로 체결 통보 구독을 시작합니다.")
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||
// [좌측 25%] 내 자산 및 통합 잔고
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
BalanceSection(tradeService) { code, name, isDom ->
|
||||
BalanceSection(tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
selectedStockQuantity = qty
|
||||
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
}
|
||||
}
|
||||
@ -105,6 +98,7 @@ fun DashboardScreen() {
|
||||
StockDetailSection(
|
||||
stockCode = selectedStockCode,
|
||||
stockName = selectedStockName,
|
||||
holdingQuantity = selectedStockQuantity,
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
wsManager = wsManager
|
||||
@ -119,19 +113,33 @@ fun DashboardScreen() {
|
||||
VerticalDivider()
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
AutoTradeSection(
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger // 트리거 전달
|
||||
) { item ->
|
||||
selectedStockCode = item.code
|
||||
selectedStockName = item.name
|
||||
isDomestic = item.isDomestic
|
||||
}
|
||||
refreshTrigger = refreshTrigger , // 트리거 전달
|
||||
onItemCancel = { item ->
|
||||
scope.launch {
|
||||
tradeService.cancelOrder(item.orderNo,item.code).onSuccess {
|
||||
refreshTrigger++
|
||||
}
|
||||
}
|
||||
},
|
||||
onItemSelect = { item ->
|
||||
selectedStockCode = item.code
|
||||
selectedStockName = item.name
|
||||
isDomestic = item.isDomestic
|
||||
})
|
||||
}
|
||||
VerticalDivider()
|
||||
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
MarketSection(tradeService) { code, name, isDom ->
|
||||
val info = StockBasicInfo(
|
||||
code = code,
|
||||
name = name,
|
||||
isDomestic = isDom
|
||||
)
|
||||
selectedStockInfo = info
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// src/main/kotlin/ui/IntegratedOrderSection.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@ -15,29 +16,56 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import network.KisTradeService
|
||||
|
||||
/**
|
||||
* 통합 주문 및 자동매매 설정 섹션
|
||||
* * [수정 사항]
|
||||
* 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지
|
||||
* 2. 수량 입력 시 콤마 제거 및 Int 변환 예외 처리 적용
|
||||
* 3. 매수 성공 시 반환받은 실제 주문번호(ODNO)를 DB에 저장하여 주문번호 중심 관리 구현
|
||||
*/
|
||||
@Composable
|
||||
fun IntegratedOrderSection(
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
isDomestic: Boolean,
|
||||
currentPrice: String,
|
||||
holdingQuantity: String,
|
||||
tradeService: KisTradeService,
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
|
||||
// 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리
|
||||
var monitoringItem by remember(stockCode) {
|
||||
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
|
||||
}
|
||||
|
||||
val isAutoSellEnabled = monitoringItem != null
|
||||
|
||||
// UI 입력 상태
|
||||
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
||||
var orderQty by remember(holdingQuantity) {
|
||||
// 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리)
|
||||
val cleanQty = holdingQuantity.replace(",", "")
|
||||
mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty)
|
||||
}
|
||||
|
||||
// 자동 매도 설정
|
||||
var isAutoSellEnabled by remember { mutableStateOf(false) }
|
||||
var profitRate by remember { mutableStateOf("5.0") }
|
||||
var stopLossRate by remember { mutableStateOf("-3.0") }
|
||||
var profitRate by remember(monitoringItem) {
|
||||
mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0")
|
||||
}
|
||||
var stopLossRate by remember(monitoringItem) {
|
||||
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-3.0")
|
||||
}
|
||||
|
||||
val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0
|
||||
val qty = orderQty.toDoubleOrNull() ?: 0.0
|
||||
// 계산용 변수
|
||||
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
|
||||
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 1. 가격 및 수량 입력
|
||||
// 가격 및 수량 입력 필드
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = orderQty,
|
||||
@ -54,71 +82,121 @@ fun IntegratedOrderSection(
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 수익률 시뮬레이션 표 (신규 추가)
|
||||
if (basePrice > 0 && qty > 0) {
|
||||
Text("익절/손절 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 11.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 4.dp))
|
||||
Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%"), true)
|
||||
SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }, false)
|
||||
SimulationColumn("예상수령액", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate ->
|
||||
val sellPrice = basePrice * rate
|
||||
val totalAmount = sellPrice * qty
|
||||
val netAmount = totalAmount * (1 - 0.0022) // 수수료+세금 약 0.22% 차감
|
||||
String.format("%,d", netAmount.toLong())
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 수익률 시뮬레이션 표
|
||||
if (basePrice > 0 && inputQty > 0) {
|
||||
SimulationCard(basePrice, inputQty.toDouble())
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 3. 자동 매도 옵션
|
||||
// 실시간 AI 매도 감시 설정 카드
|
||||
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isAutoSellEnabled, onCheckedChange = { isAutoSellEnabled = it })
|
||||
Text("매수 체결 시 자동 매도 감시 시작", fontSize = 12.sp)
|
||||
Checkbox(
|
||||
checked = isAutoSellEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (!checked) {
|
||||
// [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지)
|
||||
monitoringItem?.id?.let { dbId ->
|
||||
DatabaseFactory.deleteAutoTrade(dbId)
|
||||
monitoringItem = null
|
||||
println("🗑️ 감시 해제: $stockName (ID: $dbId)")
|
||||
}
|
||||
} else {
|
||||
// [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작
|
||||
if (curPriceNum > 0) {
|
||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||
val target = curPriceNum * (1 + pRate / 100.0)
|
||||
val stop = curPriceNum * (1 + sRate / 100.0)
|
||||
|
||||
val newItem = AutoTradeItem(
|
||||
orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}",
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
quantity = inputQty,
|
||||
profitRate = pRate,
|
||||
stopLossRate = sRate,
|
||||
targetPrice = target,
|
||||
stopLossPrice = stop,
|
||||
status = "MONITORING",
|
||||
isDomestic = isDomestic
|
||||
)
|
||||
DatabaseFactory.saveAutoTrade(newItem)
|
||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Text("실시간 AI 매도 감시 활성화", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
if (isAutoSellEnabled) {
|
||||
Row {
|
||||
OutlinedTextField(
|
||||
value = profitRate, onValueChange = { profitRate = it },
|
||||
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stopLossRate, onValueChange = { stopLossRate = it },
|
||||
label = { Text("손절 %") }, modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
OutlinedTextField(
|
||||
value = profitRate, onValueChange = { profitRate = it },
|
||||
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
enabled = !isAutoSellEnabled
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stopLossRate, onValueChange = { stopLossRate = it },
|
||||
label = { Text("손절 %") }, modifier = Modifier.weight(1f),
|
||||
enabled = !isAutoSellEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 4. 매수/매도 버튼
|
||||
// 매수 / 매도 실행 버튼
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
// 매수 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
||||
.onSuccess {
|
||||
onOrderResult(it, true)
|
||||
if (isAutoSellEnabled) { /* 자동매도 등록 로직 호출 */ }
|
||||
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
||||
onOrderResult("주문 성공: $realOrderNo", true)
|
||||
if (isAutoSellEnabled) {
|
||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
quantity = inputQty,
|
||||
profitRate = pRate,
|
||||
stopLossRate = sRate,
|
||||
targetPrice = basePrice * (1 + pRate / 100.0),
|
||||
stopLossPrice = basePrice * (1 + sRate / 100.0),
|
||||
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
||||
isDomestic = isDomestic
|
||||
))
|
||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||
}
|
||||
}
|
||||
.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
|
||||
) { Text("매수", color = Color.White) }
|
||||
|
||||
// 매도 버튼
|
||||
Button(
|
||||
onClick = { /* 매도 로직동일 */ },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = false)
|
||||
.onSuccess { realOrderNo ->
|
||||
onOrderResult("매도 주문 성공: $realOrderNo", true)
|
||||
// 매도 시 기존 감시 설정이 있다면 상태 변경 등 추가 로직 가능
|
||||
}
|
||||
.onFailure { onOrderResult(it.message ?: "매도 실패", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
|
||||
) { Text("매도", color = Color.White) }
|
||||
@ -127,16 +205,33 @@ fun IntegratedOrderSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimulationColumn(title: String, items: List<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) {
|
||||
Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray)
|
||||
items.forEach { text ->
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
color = if (text.contains("+")) Color(0xFFE03E2D) else if (text.contains("-")) Color(0xFF0E62CF) else Color.Black,
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
val color = when {
|
||||
text.contains("+") -> Color(0xFFE03E2D)
|
||||
text.contains("-") -> Color(0xFF0E62CF)
|
||||
else -> Color.Black
|
||||
}
|
||||
Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user