...
This commit is contained in:
parent
90512fc1bd
commit
edec3c4de0
@ -130,7 +130,7 @@ data class UnfilledOrder(
|
|||||||
val prdt_name: String,
|
val prdt_name: String,
|
||||||
val ord_unpr: String, // JSON이 문자열이므로 String 권장
|
val ord_unpr: String, // JSON이 문자열이므로 String 권장
|
||||||
val ord_qty : String,
|
val ord_qty : String,
|
||||||
|
val sll_buy_dvsn_cd: String,
|
||||||
@SerialName("psbl_qty")
|
@SerialName("psbl_qty")
|
||||||
val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑
|
val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑
|
||||||
val ord_dvsn_name: String,
|
val ord_dvsn_name: String,
|
||||||
@ -152,7 +152,7 @@ fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem {
|
|||||||
orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0,
|
orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0,
|
||||||
quantity = this.ord_qty.toIntOrNull() ?: 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리
|
quantity = this.ord_qty.toIntOrNull() ?: 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리
|
||||||
remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0,
|
remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0,
|
||||||
status = "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태
|
status = if (this.sll_buy_dvsn_cd.equals("01")) "SELLING" else "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태
|
||||||
isDomestic = isDomestic
|
isDomestic = isDomestic
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -164,3 +164,11 @@ data class StockBasicInfo(
|
|||||||
val isDomestic: Boolean,
|
val isDomestic: Boolean,
|
||||||
val quantity: String = "0"
|
val quantity: String = "0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ExecutionData(
|
||||||
|
val orderNo: String,
|
||||||
|
val code: String,
|
||||||
|
val price: String,
|
||||||
|
val qty: String,
|
||||||
|
val isFilled: Boolean
|
||||||
|
)
|
||||||
@ -30,7 +30,7 @@ class KisAuthService {
|
|||||||
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
||||||
install(Logging) {
|
install(Logging) {
|
||||||
logger = Logger.DEFAULT
|
logger = Logger.DEFAULT
|
||||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
level = LogLevel.NONE // 상세한 디버깅을 위해 ALL로 변경
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class KisTradeService {
|
|||||||
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
||||||
install(Logging) {
|
install(Logging) {
|
||||||
logger = Logger.DEFAULT
|
logger = Logger.DEFAULT
|
||||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
level = LogLevel.BODY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,18 +337,7 @@ class KisTradeService {
|
|||||||
} 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [추가] 국내 미체결 내역 조회
|
* [추가] 국내 미체결 내역 조회
|
||||||
@ -381,14 +370,9 @@ 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>()
|
||||||
println("result >> ${body.msg1}")
|
|
||||||
println("result >> ${body.rt_cd}")
|
|
||||||
if (body.rt_cd == "0") {
|
if (body.rt_cd == "0") {
|
||||||
|
|
||||||
var result = body
|
var result = body
|
||||||
println("result >> ${result.output.size}")
|
|
||||||
Result.success(result.output)
|
Result.success(result.output)
|
||||||
}
|
}
|
||||||
else Result.failure(Exception(body.msg1))
|
else Result.failure(Exception(body.msg1))
|
||||||
|
|||||||
@ -6,11 +6,19 @@ 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.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
|
import util.AesCrypto
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class KisWebSocketManager {
|
class KisWebSocketManager {
|
||||||
private val client = HttpClient { install(WebSockets) }
|
private val client = HttpClient {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingInterval = 20_000 // 20초마다 표준 웹소켓 핑 전송 (서버-클라이언트 연결 유지 도움)
|
||||||
|
}
|
||||||
|
}
|
||||||
private var session: DefaultClientWebSocketSession? = null
|
private var session: DefaultClientWebSocketSession? = null
|
||||||
private val isConnected = AtomicBoolean(false)
|
private val isConnected = AtomicBoolean(false)
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
@ -21,27 +29,42 @@ class KisWebSocketManager {
|
|||||||
|
|
||||||
suspend fun connect() {
|
suspend fun connect() {
|
||||||
if (isConnected.get()) return
|
if (isConnected.get()) return
|
||||||
val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:21000" else "ops.koreainvestment.com:31000"
|
val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:31000" else "ops.koreainvestment.com:21000"
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
while (isActive) { // 재연결을 위한 루프 추가
|
||||||
client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") {
|
try {
|
||||||
session = this
|
client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") {
|
||||||
isConnected.set(true)
|
session = this
|
||||||
println("✅ 웹소켓 연결 성공")
|
isConnected.set(true)
|
||||||
|
println("✅ 웹소켓 연결 성공")
|
||||||
|
|
||||||
// 연결 직후 HTS ID 기반 체결 통보 자동 구독
|
// 기존 구독 신청 로직 (H0STCNI0 등)
|
||||||
val htsId = KisSession.config.htsId
|
val htsId = KisSession.config.htsId
|
||||||
if (htsId.isNotEmpty()) sendRequest("1", "H0STT084R", htsId)
|
if (htsId.isNotEmpty()) sendRequest("1", "H0STCNI0", htsId)
|
||||||
|
|
||||||
for (frame in incoming) {
|
// 메시지 수신 루프
|
||||||
if (frame is Frame.Text) handleMessage(frame.readText())
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
// [핵심] PINGPONG 처리: 수신된 텍스트 그대로 다시 전송
|
||||||
|
if (text.contains("PINGPONG")) {
|
||||||
|
send(Frame.Text(text))
|
||||||
|
// println("🔄 PINGPONG 응답 완료")
|
||||||
|
} else {
|
||||||
|
handleMessage(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("❌ 웹소켓 연결 끊김: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
isConnected.set(false)
|
||||||
|
session = null
|
||||||
|
println("⏳ 5초 후 재연결 시도...")
|
||||||
|
delay(5000) // 5초 후 재연결 시도
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
println("❌ 웹소켓 에러: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
isConnected.set(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,38 +79,76 @@ class KisWebSocketManager {
|
|||||||
subscribeStock(code, isSubscribe = false)
|
subscribeStock(code, isSubscribe = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 체결 통보 복호화를 위한 키 저장소
|
||||||
|
private var aesKey: String = ""
|
||||||
|
private var aesIv: String = ""
|
||||||
|
|
||||||
private fun handleMessage(message: String) {
|
private fun handleMessage(message: String) {
|
||||||
|
if (message.startsWith("{")) {
|
||||||
|
val json = Json.parseToJsonElement(message).jsonObject
|
||||||
|
val trId = json["header"]?.jsonObject?.get("tr_id")?.jsonPrimitive?.content
|
||||||
|
|
||||||
|
if (trId == "H0STCNI0" || trId == "H0STCNI9") {
|
||||||
|
val output = json["body"]?.jsonObject?.get("output")?.jsonObject
|
||||||
|
aesKey = output?.get("key")?.jsonPrimitive?.content ?: ""
|
||||||
|
aesIv = output?.get("iv")?.jsonPrimitive?.content ?: ""
|
||||||
|
println("🔑 복호화 키 획득 완료: KEY[$aesKey]")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!message.startsWith("0") && !message.startsWith("1")) return
|
if (!message.startsWith("0") && !message.startsWith("1")) return
|
||||||
|
// 2. 실시간 데이터 처리
|
||||||
val parts = message.split("|")
|
val parts = message.split("|")
|
||||||
if (parts.size < 4) return
|
if (parts.size < 4) return
|
||||||
|
|
||||||
|
val leadingChar = message[0] // '0' 또는 '1'
|
||||||
val trId = parts[1]
|
val trId = parts[1]
|
||||||
val dataRows = parts[3].split("^")
|
|
||||||
|
|
||||||
when (trId) {
|
when (leadingChar) {
|
||||||
"H0STCNT0" -> {
|
'0' -> { // 일반 시세 (암호화 안됨)
|
||||||
val price = dataRows[2]
|
if (trId == "H0STCNT0") {
|
||||||
_currentPrice.value = price // 상태 업데이트
|
val dataRows = parts[3].split("^")
|
||||||
onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0)
|
val price = dataRows[2]
|
||||||
|
_currentPrice.value = price // 상태 업데이트
|
||||||
|
onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0)
|
||||||
|
|
||||||
// 로그 추가 (예시)
|
// 로그 추가 (예시)
|
||||||
tradeLogs.add(0, model.RealTimeTrade(
|
tradeLogs.add(
|
||||||
time = dataRows[1],
|
0, model.RealTimeTrade(
|
||||||
price = price,
|
time = dataRows[1],
|
||||||
change = dataRows[4],
|
price = price,
|
||||||
volume = dataRows[2],
|
change = dataRows[4],
|
||||||
type = model.TradeType.NEUTRAL
|
volume = dataRows[2],
|
||||||
))
|
type = model.TradeType.NEUTRAL
|
||||||
if (tradeLogs.size > 50) tradeLogs.removeLast()
|
)
|
||||||
|
)
|
||||||
|
if (tradeLogs.size > 50) tradeLogs.removeLast()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"H0STT084R" -> {
|
|
||||||
println("채결 데이터")
|
'1' -> { // 체결 통보 (암호화 됨)
|
||||||
onExecutionReceived?.invoke(dataRows[5], dataRows[9], dataRows[12], dataRows[13], dataRows[15] == "02")
|
if ((trId == "H0STCNI0" || trId == "H0STCNI9") && aesKey.isNotEmpty()) {
|
||||||
}
|
// AES 복호화 실행
|
||||||
else -> {
|
val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv)
|
||||||
println("쓰레기? ${trId}")
|
val dataRows = decryptedData.split("^")
|
||||||
|
|
||||||
|
println("🔔 복호화된 체결 통보: ${dataRows[8]} ${dataRows[9]}주 ${dataRows[13]} 체결")
|
||||||
|
|
||||||
|
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
|
||||||
|
onExecutionReceived?.invoke(
|
||||||
|
dataRows[8], // 주식단축종목코드
|
||||||
|
dataRows[9], // 체결수량
|
||||||
|
dataRows[10], // 체결단가
|
||||||
|
dataRows[2], // 주문번호
|
||||||
|
dataRows[13] == "2" // 체결여부 (02: 체결)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearData() {
|
fun clearData() {
|
||||||
tradeLogs.clear()
|
tradeLogs.clear()
|
||||||
_currentPrice.value = "0"
|
_currentPrice.value = "0"
|
||||||
|
|||||||
@ -72,7 +72,7 @@ fun ActiveTradeRow(
|
|||||||
val detailText = when (item.status) {
|
val detailText = when (item.status) {
|
||||||
"PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
|
"PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
|
||||||
"MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
|
"MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
|
||||||
else -> "주문번호: ${item.orderNo}"
|
else -> "주문번호: ${item.orderNo} ${item.orderedPrice} ${item.quantity}"
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
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.ExecutionData
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import model.StockBasicInfo
|
import model.StockBasicInfo
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
@ -22,7 +23,6 @@ fun DashboardScreen() {
|
|||||||
val tradeService = remember { KisTradeService() }
|
val tradeService = remember { KisTradeService() }
|
||||||
val wsManager = remember { KisWebSocketManager() }
|
val wsManager = remember { KisWebSocketManager() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
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) }
|
||||||
@ -31,7 +31,53 @@ fun DashboardScreen() {
|
|||||||
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
||||||
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
||||||
|
|
||||||
|
// 중앙 관리용 상태들
|
||||||
|
var refreshTrigger by remember { mutableStateOf(0) }
|
||||||
|
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
|
||||||
|
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
|
||||||
|
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
|
||||||
|
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
|
||||||
|
suspend fun syncAndExecute(orderNo: String) {
|
||||||
|
// 이미 다른 코루틴에서 이 주문을 처리 중이라면 즉시 종료 (중복 방지)
|
||||||
|
if (processingIds.contains(orderNo)) return
|
||||||
|
processingIds.add(orderNo)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// DB 아이템과 체결 데이터(캐시)를 모두 가져옴
|
||||||
|
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||||
|
val execData = executionCache[orderNo]
|
||||||
|
|
||||||
|
// 둘 다 존재할 때만 로직 실행
|
||||||
|
if (dbItem != null && execData != null && execData.isFilled) {
|
||||||
|
if (dbItem.status == TradeStatus.PENDING_BUY) {
|
||||||
|
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} (${orderNo})")
|
||||||
|
|
||||||
|
val sellPrice = dbItem.targetPrice.toLong().toString()
|
||||||
|
tradeService.postOrder(
|
||||||
|
stockCode = dbItem.code,
|
||||||
|
qty = dbItem.quantity.toString(),
|
||||||
|
price = sellPrice,
|
||||||
|
isBuy = false
|
||||||
|
).onSuccess { newSellOrderNo ->
|
||||||
|
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
|
||||||
|
// 처리가 완료된 체결 데이터는 캐시에서 삭제
|
||||||
|
executionCache.remove(orderNo)
|
||||||
|
refreshTrigger++
|
||||||
|
}.onFailure {
|
||||||
|
println("❌ 익절 주문 실패: ${it.message}")
|
||||||
|
}
|
||||||
|
} else if (dbItem.status == TradeStatus.SELLING) {
|
||||||
|
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
|
||||||
|
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||||
|
executionCache.remove(orderNo)
|
||||||
|
refreshTrigger++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 처리가 끝나면(성공/실패/매칭실패 모두) 잠금 해제
|
||||||
|
processingIds.remove(orderNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// 1. 웹소켓 연결
|
// 1. 웹소켓 연결
|
||||||
@ -55,39 +101,12 @@ fun DashboardScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
||||||
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
|
wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
println("$orderNo, $code, $price, $qty, $isBuy")
|
||||||
if (dbItem != null) {
|
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
|
||||||
when (dbItem.status) {
|
executionCache[orderNo] = exec
|
||||||
TradeStatus.PENDING_BUY -> {
|
syncAndExecute(orderNo)
|
||||||
// 1. 매수 주문 체결 확인됨 -> 즉시 익절 매도 주문 발주
|
|
||||||
println("✅ 매수 체결 확인 [${dbItem.name}]: 익절가 ${dbItem.targetPrice}로 매도 주문을 생성합니다.")
|
|
||||||
tradeService.postOrder(
|
|
||||||
stockCode = dbItem.code,
|
|
||||||
qty = dbItem.quantity.toString(),
|
|
||||||
price = dbItem.targetPrice.toLong().toString(), // 가격은 정수형 문자열로 전달
|
|
||||||
isBuy = false
|
|
||||||
).onSuccess { newSellOrderNo ->
|
|
||||||
// 2. 매도 주문 성공 시 DB 상태를 SELLING으로 변경하고 새로운 주문번호로 갱신
|
|
||||||
DatabaseFactory.updateStatusAndOrderNo(
|
|
||||||
id = dbItem.id!!,
|
|
||||||
newStatus = TradeStatus.SELLING,
|
|
||||||
newOrderNo = newSellOrderNo
|
|
||||||
)
|
|
||||||
println("🚀 익절 매도 주문 완료: 주문번호 $newSellOrderNo")
|
|
||||||
refreshTrigger++ // UI 갱신
|
|
||||||
}.onFailure {
|
|
||||||
println("❌ 매수 체결 후 익절 주문 발주 실패: ${it.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TradeStatus.SELLING -> {
|
|
||||||
// 매도(손절/익절) 주문 체결 -> COMPLETED
|
|
||||||
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshTrigger++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,7 +136,12 @@ fun DashboardScreen() {
|
|||||||
holdingQuantity = selectedStockQuantity,
|
holdingQuantity = selectedStockQuantity,
|
||||||
isDomestic = isDomestic,
|
isDomestic = isDomestic,
|
||||||
tradeService = tradeService,
|
tradeService = tradeService,
|
||||||
wsManager = wsManager
|
wsManager = wsManager,
|
||||||
|
onOrderSaved = { orderNo ->
|
||||||
|
scope.launch {
|
||||||
|
syncAndExecute(orderNo) // 매칭 시도
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
|
import network.KisWebSocketManager
|
||||||
|
import util.MarketUtil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통합 주문 및 자동매매 설정 섹션
|
* 통합 주문 및 자동매매 설정 섹션
|
||||||
@ -31,6 +33,7 @@ fun IntegratedOrderSection(
|
|||||||
currentPrice: String,
|
currentPrice: String,
|
||||||
holdingQuantity: String,
|
holdingQuantity: String,
|
||||||
tradeService: KisTradeService,
|
tradeService: KisTradeService,
|
||||||
|
onOrderSaved: (String) -> Unit,
|
||||||
onOrderResult: (String, Boolean) -> Unit
|
onOrderResult: (String, Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -40,7 +43,16 @@ fun IntegratedOrderSection(
|
|||||||
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
|
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
val isAutoSellEnabled = monitoringItem != null
|
var activeMonitoringItem by remember(stockCode) {
|
||||||
|
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 체크박스의 '의도' 상태 (신규 매수 시 자동감시를 켤 것인지 여부)
|
||||||
|
// 감시 중인 아이템이 있으면 true, 없으면 사용자 선택에 따름
|
||||||
|
var willEnableAutoSell by remember(stockCode) {
|
||||||
|
mutableStateOf(activeMonitoringItem != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// UI 입력 상태
|
// UI 입력 상태
|
||||||
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
||||||
@ -94,8 +106,9 @@ fun IntegratedOrderSection(
|
|||||||
Column(modifier = Modifier.padding(8.dp)) {
|
Column(modifier = Modifier.padding(8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = isAutoSellEnabled,
|
checked = willEnableAutoSell,
|
||||||
onCheckedChange = { checked ->
|
onCheckedChange = { checked ->
|
||||||
|
willEnableAutoSell = checked
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
// [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지)
|
// [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지)
|
||||||
monitoringItem?.id?.let { dbId ->
|
monitoringItem?.id?.let { dbId ->
|
||||||
@ -105,27 +118,27 @@ fun IntegratedOrderSection(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작
|
// [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작
|
||||||
if (curPriceNum > 0) {
|
// if (curPriceNum > 0) {
|
||||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
// val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
// val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||||
val target = curPriceNum * (1 + pRate / 100.0)
|
// val target = curPriceNum * (1 + pRate / 100.0)
|
||||||
val stop = curPriceNum * (1 + sRate / 100.0)
|
// val stop = curPriceNum * (1 + sRate / 100.0)
|
||||||
|
//
|
||||||
val newItem = AutoTradeItem(
|
// val newItem = AutoTradeItem(
|
||||||
orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}",
|
// orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}",
|
||||||
code = stockCode,
|
// code = stockCode,
|
||||||
name = stockName,
|
// name = stockName,
|
||||||
quantity = inputQty,
|
// quantity = inputQty,
|
||||||
profitRate = pRate,
|
// profitRate = pRate,
|
||||||
stopLossRate = sRate,
|
// stopLossRate = sRate,
|
||||||
targetPrice = target,
|
// targetPrice = target,
|
||||||
stopLossPrice = stop,
|
// stopLossPrice = stop,
|
||||||
status = "MONITORING",
|
// status = "MONITORING",
|
||||||
isDomestic = isDomestic
|
// isDomestic = isDomestic
|
||||||
)
|
// )
|
||||||
DatabaseFactory.saveAutoTrade(newItem)
|
// DatabaseFactory.saveAutoTrade(newItem)
|
||||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
// monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -136,12 +149,12 @@ fun IntegratedOrderSection(
|
|||||||
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
|
enabled = !willEnableAutoSell
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = stopLossRate, onValueChange = { stopLossRate = it },
|
value = stopLossRate, onValueChange = { stopLossRate = it },
|
||||||
label = { Text("손절 %") }, modifier = Modifier.weight(1f),
|
label = { Text("손절 %") }, modifier = Modifier.weight(1f),
|
||||||
enabled = !isAutoSellEnabled
|
enabled = !willEnableAutoSell
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -159,9 +172,12 @@ fun IntegratedOrderSection(
|
|||||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
||||||
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
||||||
onOrderResult("주문 성공: $realOrderNo", true)
|
onOrderResult("주문 성공: $realOrderNo", true)
|
||||||
if (isAutoSellEnabled) {
|
if (willEnableAutoSell) {
|
||||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||||
|
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0))
|
||||||
|
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
||||||
|
|
||||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||||
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
||||||
code = stockCode,
|
code = stockCode,
|
||||||
@ -169,12 +185,14 @@ fun IntegratedOrderSection(
|
|||||||
quantity = inputQty,
|
quantity = inputQty,
|
||||||
profitRate = pRate,
|
profitRate = pRate,
|
||||||
stopLossRate = sRate,
|
stopLossRate = sRate,
|
||||||
targetPrice = basePrice * (1 + pRate / 100.0),
|
targetPrice = calculatedTarget,
|
||||||
stopLossPrice = basePrice * (1 + sRate / 100.0),
|
stopLossPrice = calculatedStop,
|
||||||
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
||||||
isDomestic = isDomestic
|
isDomestic = isDomestic
|
||||||
))
|
))
|
||||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||||
|
onOrderSaved(realOrderNo)
|
||||||
|
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
||||||
|
|||||||
@ -41,7 +41,8 @@ fun StockDetailSection(
|
|||||||
holdingQuantity : String,
|
holdingQuantity : String,
|
||||||
isDomestic: Boolean,
|
isDomestic: Boolean,
|
||||||
tradeService: KisTradeService,
|
tradeService: KisTradeService,
|
||||||
wsManager: KisWebSocketManager
|
wsManager: KisWebSocketManager,
|
||||||
|
onOrderSaved: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var openPrice by remember { mutableStateOf("0") }
|
var openPrice by remember { mutableStateOf("0") }
|
||||||
@ -220,6 +221,7 @@ fun StockDetailSection(
|
|||||||
currentPrice = wsManager.currentPrice.value,
|
currentPrice = wsManager.currentPrice.value,
|
||||||
holdingQuantity = holdingQuantity,
|
holdingQuantity = holdingQuantity,
|
||||||
tradeService = tradeService,
|
tradeService = tradeService,
|
||||||
|
onOrderSaved = onOrderSaved,
|
||||||
onOrderResult = { msg, success ->
|
onOrderResult = { msg, success ->
|
||||||
resultMessage = msg
|
resultMessage = msg
|
||||||
isSuccess = success
|
isSuccess = success
|
||||||
|
|||||||
26
src/main/kotlin/util/AesCrypto.kt
Normal file
26
src/main/kotlin/util/AesCrypto.kt
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// src/main/kotlin/util/AesCrypto.kt
|
||||||
|
package util
|
||||||
|
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
object AesCrypto {
|
||||||
|
fun decrypt(cipherText: String, key: String, iv: String): String {
|
||||||
|
return try {
|
||||||
|
val keySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), "AES")
|
||||||
|
val ivSpec = IvParameterSpec(iv.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
|
||||||
|
|
||||||
|
val decodedBytes = Base64.getDecoder().decode(cipherText)
|
||||||
|
val decryptedBytes = cipher.doFinal(decodedBytes)
|
||||||
|
|
||||||
|
String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"복호화 실패: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user