....
This commit is contained in:
parent
2bb94e2856
commit
bdc268e325
@ -45,6 +45,7 @@ dependencies {
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
|
||||
@ -39,6 +39,7 @@ fun main() = application {
|
||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||
isSimulation = it[ConfigTable.isSimulation],
|
||||
htsId = it[ConfigTable.htsId],
|
||||
modelPath = it[ConfigTable.modelPath]
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import model.AppConfig
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.javatime.datetime
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import java.io.File
|
||||
@ -16,37 +17,110 @@ object ConfigTable : Table("app_config") {
|
||||
val vtsAccountNo = varchar("vts_account_no", 20).default("")
|
||||
val isSimulation = bool("is_simulation").default(true)
|
||||
val modelPath = varchar("model_path", 512).default("")
|
||||
val htsId = varchar("hts_id", 50).default("") // HTS ID 컬럼 추가
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// 2. 거래 내역 테이블 (대량 데이터용)
|
||||
// 2. 자동매매 감시 테이블
|
||||
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 isDomestic = bool("is_domestic").default(true)
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// 3. 거래 내역 테이블
|
||||
object TradeLogTable : Table("trade_logs") {
|
||||
val id = long("id").autoIncrement()
|
||||
val stockCode = varchar("stock_code", 20) // 종목코드
|
||||
val stockName = varchar("stock_name", 50) // 종목명
|
||||
val tradeType = varchar("trade_type", 10) // 매수/매도
|
||||
val price = double("price") // 체결가
|
||||
val quantity = integer("quantity") // 수량
|
||||
val timestamp = datetime("timestamp") // 거래 시간
|
||||
val logMessage = text("log_message") // Ollama의 판단 근거 등 상세 정보
|
||||
val stockCode = varchar("stock_code", 20)
|
||||
val stockName = varchar("stock_name", 50)
|
||||
val tradeType = varchar("trade_type", 10)
|
||||
val price = double("price")
|
||||
val quantity = integer("quantity")
|
||||
val timestamp = datetime("timestamp")
|
||||
val logMessage = text("log_message")
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
object DatabaseFactory {
|
||||
fun init() {
|
||||
|
||||
val dbPath =File("db/autotrade_db").absolutePath
|
||||
// 드라이버를 org.h2.Driver로 설정
|
||||
val dbPath = File("db/autotrade_db").absolutePath
|
||||
Database.connect(
|
||||
"jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;",
|
||||
driver = "org.h2.Driver"
|
||||
)
|
||||
|
||||
transaction {
|
||||
SchemaUtils.create(ConfigTable, TradeLogTable)
|
||||
// 테이블 생성 (AutoTradeTable 포함)
|
||||
SchemaUtils.create(ConfigTable, TradeLogTable, AutoTradeTable)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 자동매매(감시) 관련 함수 ---
|
||||
|
||||
/**
|
||||
* [추가] 종목코드로 현재 감시 중인 설정 가져오기 (웹소켓 감시용)
|
||||
*/
|
||||
fun findConfigByCode(code: String): AutoTradeItem? = transaction {
|
||||
AutoTradeTable.select {
|
||||
(AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING")
|
||||
}.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(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(
|
||||
code = it[AutoTradeTable.stockCode],
|
||||
name = it[AutoTradeTable.stockName],
|
||||
targetPrice = it[AutoTradeTable.targetPrice],
|
||||
stopLossPrice = it[AutoTradeTable.stopLossPrice],
|
||||
status = it[AutoTradeTable.status],
|
||||
isDomestic = it[AutoTradeTable.isDomestic]
|
||||
)
|
||||
|
||||
// --- 기존 설정 및 로그 관련 함수 ---
|
||||
|
||||
fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) {
|
||||
transaction {
|
||||
TradeLogTable.insert {
|
||||
@ -61,28 +135,26 @@ object DatabaseFactory {
|
||||
}
|
||||
}
|
||||
|
||||
fun findConfigByAccount(accountNo: String): AppConfig? {
|
||||
return transaction {
|
||||
ConfigTable.select {
|
||||
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
|
||||
}.lastOrNull()?.let {
|
||||
AppConfig(
|
||||
realAppKey = it[ConfigTable.realAppKey],
|
||||
realSecretKey = it[ConfigTable.realSecretKey],
|
||||
realAccountNo = it[ConfigTable.realAccountNo],
|
||||
vtsAppKey = it[ConfigTable.vtsAppKey],
|
||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||
isSimulation = it[ConfigTable.isSimulation],
|
||||
modelPath = it[ConfigTable.modelPath]
|
||||
)
|
||||
}
|
||||
fun findConfigByAccount(accountNo: String): AppConfig? = transaction {
|
||||
ConfigTable.select {
|
||||
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
|
||||
}.lastOrNull()?.let {
|
||||
AppConfig(
|
||||
realAppKey = it[ConfigTable.realAppKey],
|
||||
realSecretKey = it[ConfigTable.realSecretKey],
|
||||
realAccountNo = it[ConfigTable.realAccountNo],
|
||||
vtsAppKey = it[ConfigTable.vtsAppKey],
|
||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||
isSimulation = it[ConfigTable.isSimulation],
|
||||
htsId = it[ConfigTable.htsId], // htsId 로드
|
||||
modelPath = it[ConfigTable.modelPath]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig(config: AppConfig) {
|
||||
transaction {
|
||||
// 기존 설정을 모두 지우고 최신 설정 하나만 유지
|
||||
ConfigTable.deleteAll()
|
||||
ConfigTable.insert {
|
||||
it[realAppKey] = config.realAppKey
|
||||
@ -92,9 +164,21 @@ object DatabaseFactory {
|
||||
it[realAccountNo] = config.realAccountNo
|
||||
it[vtsAccountNo] = config.vtsAccountNo
|
||||
it[isSimulation] = config.isSimulation
|
||||
it[htsId] = config.htsId
|
||||
it[modelPath] = config.modelPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* [수정] 감시 가격(익절/손절) 정보를 포함하도록 모델 확장
|
||||
*/
|
||||
data class AutoTradeItem(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val targetPrice: Double,
|
||||
val stopLossPrice: Double, // 손절가 추가
|
||||
val status: String,
|
||||
val isDomestic: Boolean
|
||||
)
|
||||
@ -21,6 +21,8 @@ data class AppConfig(
|
||||
var tradeToken: String = "",
|
||||
var tradeTokenExpiredAt: LocalDateTime? = null,
|
||||
|
||||
val htsId: String = "",
|
||||
|
||||
var websocketToken: String = "",
|
||||
val isSimulation: Boolean = true,
|
||||
val modelPath: String = "") {
|
||||
|
||||
@ -65,14 +65,15 @@ data class RankingStock(
|
||||
val hts_kor_alph_nm: String = "", // 종목명
|
||||
val mkrtc_objt_iscd: String = "", // 종목코드
|
||||
val mksc_shrn_iscd: String = "", // 종목코드
|
||||
val stck_shrn_iscd: String = "", // 종목코드
|
||||
val stck_prpr: String = "0", // 현재가
|
||||
val prdy_ctrt: String = "0.0", // 등락률
|
||||
val mrkt_div_cls_code : String = "J",
|
||||
) {
|
||||
val name : String
|
||||
get() = hts_kor_isnm ?: hts_kor_alph_nm ?: mkrtc_objt_iscd ?: ""
|
||||
get() = listOf(hts_kor_isnm , hts_kor_alph_nm , mkrtc_objt_iscd).firstOrNull { it.isNotBlank() } ?: ""
|
||||
val code : String
|
||||
get() = mksc_shrn_iscd ?: mkrtc_objt_iscd ?: hts_kor_isnm ?: ""
|
||||
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd , hts_kor_isnm).firstOrNull { it.isNotBlank() } ?: ""
|
||||
}
|
||||
@Serializable
|
||||
data class OverseasRankingResponse(
|
||||
@ -116,3 +117,37 @@ data class UnifiedBalance(
|
||||
val totalProfitRate: String, // 총 수익률
|
||||
val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트
|
||||
)
|
||||
|
||||
|
||||
@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: 매수)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnfilledResponse(
|
||||
val rt_cd: String,
|
||||
val msg1: String,
|
||||
val output: List<UnfilledOrder> = emptyList()
|
||||
)
|
||||
|
||||
// src/main/kotlin/model/TradeModels.kt 내 추가
|
||||
enum class ActiveTradeType { MONITORING, UNFILLED }
|
||||
|
||||
data class ActiveTradeItem(
|
||||
val id: String, // DB ID 또는 주문번호
|
||||
val code: String,
|
||||
val name: String,
|
||||
val type: ActiveTradeType,
|
||||
val price: Double, // 목표가 또는 주문단가
|
||||
val quantity: String, // 미체결 수량 (감시 중에는 "-")
|
||||
val isDomestic: Boolean
|
||||
)
|
||||
@ -25,6 +25,9 @@ import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import model.*
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class KisTradeService {
|
||||
private val client = HttpClient(CIO) {
|
||||
@ -173,6 +176,66 @@ class KisTradeService {
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [추가] 기간별(일/주/월) 차트 데이터 조회
|
||||
* @param periodCode "D"(일), "W"(주), "M"(월)
|
||||
*/
|
||||
suspend fun fetchPeriodChartData(
|
||||
stockCode: String,
|
||||
periodCode: String = "D",
|
||||
isDomestic: Boolean = true
|
||||
): Result<List<CandleData>> {
|
||||
val config = KisSession.config
|
||||
val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
|
||||
val today = LocalDate.now()
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
val endDate = today.format(formatter)
|
||||
|
||||
// [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전)
|
||||
// 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다.
|
||||
val startDate = when (periodCode) {
|
||||
"D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분
|
||||
"W" -> today.minusYears(2).format(formatter) // 주봉: 2년치
|
||||
"M" -> today.minusYears(8).format(formatter) // 월봉: 8년치
|
||||
else -> today.minusYears(1).format(formatter)
|
||||
}
|
||||
return try {
|
||||
val response = client.get("$prodUrl$path") {
|
||||
header("authorization", "Bearer ${config.marketToken}")
|
||||
header("appkey", config.realAppKey)
|
||||
header("appsecret", config.realSecretKey)
|
||||
header("tr_id", if (isDomestic) "FHKST03010100" else "HHDFS76240000")
|
||||
header("custtype", "P")
|
||||
|
||||
parameter("FID_INPUT_DATE_1", startDate)
|
||||
parameter("FID_INPUT_DATE_2", endDate)
|
||||
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
||||
parameter("FID_INPUT_ISCD", stockCode)
|
||||
parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M
|
||||
parameter("FID_ORG_ADJ_PRC", "0")
|
||||
}
|
||||
|
||||
val body = response.body<JsonObject>()
|
||||
val output2 = body["output2"]?.jsonArray
|
||||
|
||||
val candles = output2?.map { element ->
|
||||
val obj = element.jsonObject
|
||||
CandleData(
|
||||
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
|
||||
stck_clpr = obj["stck_clpr"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
|
||||
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",
|
||||
acml_vol = obj["acml_vol"]?.jsonPrimitive?.content ?: "0"
|
||||
)
|
||||
}?.reversed() ?: emptyList()
|
||||
|
||||
Result.success(candles)
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [해외 주식 순위] 모델 매핑 오류 수정
|
||||
*/
|
||||
@ -217,22 +280,98 @@ class KisTradeService {
|
||||
suspend fun postOrder(
|
||||
stockCode: String,
|
||||
qty: String,
|
||||
price: String, // "0" 이면 시장가
|
||||
price: String,
|
||||
isBuy: Boolean
|
||||
): Result<String> {
|
||||
val config = KisSession.config
|
||||
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
|
||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
||||
|
||||
// 계좌번호 처리: 8자리면 01 자동 추가
|
||||
var pureAccount = config.accountNo.replace("-", "").trim()
|
||||
if (pureAccount.length == 8) pureAccount += "01"
|
||||
|
||||
val cano = pureAccount.take(8)
|
||||
val acntPrdtCd = pureAccount.takeLast(2)
|
||||
|
||||
val trId = when {
|
||||
isDomestic && config.isSimulation -> if (isBuy) "VTRP0001U" else "VTRP0002U"
|
||||
isDomestic && config.isSimulation -> if (isBuy) "VTTC0802U" else "VTTC0801U"
|
||||
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
|
||||
!isDomestic && config.isSimulation -> if (isBuy) "VTTT3001U" else "VTTT3002U"
|
||||
else -> if (isBuy) "TTTS3001U" else "TTTS3002U"
|
||||
else -> if (isBuy) "TTTS3002U" else "TTTS3001U"
|
||||
}
|
||||
|
||||
return try {
|
||||
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
|
||||
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)
|
||||
header("custtype", "P") // [해결] 필수 헤더 추가
|
||||
header("Content-Type", "application/json")
|
||||
|
||||
setBody(mapOf(
|
||||
"CANO" to cano,
|
||||
"ACNT_PRDT_CD" to acntPrdtCd,
|
||||
"PDNO" to stockCode,
|
||||
"ORD_DVSN" to if (price == "0" || price.isEmpty()) "01" else "00",
|
||||
"ORD_QTY" to qty,
|
||||
"ORD_UNPR" to if (price.isEmpty() || price == "0") "0" else price
|
||||
))
|
||||
}
|
||||
|
||||
val body = response.body<JsonObject>() // [해결] Polymorphic 직렬화 에러 방지
|
||||
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"))
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* [추가] 국내 미체결 내역 조회
|
||||
*/
|
||||
suspend fun fetchUnfilledOrders(): Result<List<UnfilledOrder>> {
|
||||
val config = KisSession.config
|
||||
if (config.isSimulation) Result.success(emptyList<UnfilledOrder>())
|
||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
||||
val trId = "TTTC0084R"
|
||||
|
||||
return try {
|
||||
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl") {
|
||||
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)
|
||||
header("custtype", "P")
|
||||
|
||||
parameter("CANO", config.accountNo.take(8))
|
||||
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2))
|
||||
parameter("CTX_AREA_FK100", "")
|
||||
parameter("CTX_AREA_NK100", "")
|
||||
parameter("T_GUBUN", "0")
|
||||
parameter("LOAN_DT", "")
|
||||
parameter("P_S_GUBUN", "0")
|
||||
parameter("INQR_DVSN_1", "0")
|
||||
parameter("INQR_DVSN_2", "0")
|
||||
}
|
||||
val body = response.body<UnfilledResponse>()
|
||||
if (body.rt_cd == "0") Result.success(body.output)
|
||||
else Result.failure(Exception(body.msg1))
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [추가] 주문 취소 (정정/취소 API)
|
||||
*/
|
||||
suspend fun cancelOrder(orgNo: String, stockCode: String): Result<String> {
|
||||
val config = KisSession.config
|
||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
||||
val trId = if (config.isSimulation) "VTTC0803U" else "TTTC0803U"
|
||||
|
||||
return try {
|
||||
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)
|
||||
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
||||
@ -242,15 +381,17 @@ class KisTradeService {
|
||||
setBody(mapOf(
|
||||
"CANO" to config.accountNo.take(8),
|
||||
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
|
||||
"PDNO" to stockCode,
|
||||
"ORD_DVSN" to if (price == "0") "01" else "00",
|
||||
"ORD_QTY" to qty,
|
||||
"ORD_UNPR" to price
|
||||
"KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호
|
||||
"ORGN_ORD_NO" to orgNo, // 취소할 원주문번호
|
||||
"RVSE_CNCL_DVSN" to "02", // 01: 정정, 02: 취소
|
||||
"ORD_DVSN" to "00", // 지정가
|
||||
"ORD_QTY" to "0", // 0이면 전량 취소
|
||||
"ORD_UNPR" to "0"
|
||||
))
|
||||
}
|
||||
val body = response.body<Map<String, Any>>()
|
||||
if (body["rt_cd"] == "0") Result.success("✅ 주문 성공: ${body["msg1"]}")
|
||||
else Result.failure(Exception("${body["msg1"]}"))
|
||||
val body = response.body<JsonObject>()
|
||||
if (body["rt_cd"]?.jsonPrimitive?.content == "0") Result.success("취소 완료")
|
||||
else Result.failure(Exception(body["msg1"]?.jsonPrimitive?.content))
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
}
|
||||
|
||||
@ -284,7 +425,12 @@ class KisTradeService {
|
||||
val path = if (isDomestic)
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
|
||||
val now = LocalTime.now()
|
||||
val searchTime = if (now.isAfter(LocalTime.of(15, 30))) {
|
||||
"153000"
|
||||
} else {
|
||||
now.format(DateTimeFormatter.ofPattern("HHmmss"))
|
||||
}
|
||||
return try {
|
||||
val response = client.get("$prodUrl$path") {
|
||||
header("authorization", "Bearer ${config.marketToken}")
|
||||
@ -297,7 +443,7 @@ class KisTradeService {
|
||||
parameter("FID_ETC_CLS_CODE", "")
|
||||
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
||||
parameter("FID_INPUT_ISCD", stockCode)
|
||||
parameter("FID_INPUT_HOUR_1", "153000") // 장 마감 시간까지
|
||||
parameter("FID_INPUT_HOUR_1", searchTime) // 장 마감 시간까지
|
||||
parameter("FID_PW_DATA_INCU_YN", "Y") // 전일 데이터 포함 여부
|
||||
}
|
||||
|
||||
|
||||
@ -6,61 +6,49 @@ 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.logging.DEFAULT
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import model.AppConfig
|
||||
import model.KisSession
|
||||
import model.RealTimeTrade
|
||||
import model.TradeType
|
||||
|
||||
class KisWebSocketManager {
|
||||
private val client = HttpClient(CIO) {
|
||||
install(WebSockets) {
|
||||
pingInterval = 20_000
|
||||
}
|
||||
install(WebSockets) { pingInterval = 20_000 }
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 15_000
|
||||
connectTimeoutMillis = 15_000
|
||||
socketTimeoutMillis = 15_000
|
||||
}
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
||||
}
|
||||
}
|
||||
|
||||
private var session: DefaultClientWebSocketSession? = null
|
||||
private val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||
|
||||
// UI 관찰 상태값
|
||||
// UI 상태값
|
||||
val currentPrice = mutableStateOf("0")
|
||||
val priceChangeColor = mutableStateOf(Color.Transparent)
|
||||
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
|
||||
|
||||
suspend fun connect() {
|
||||
val config = KisSession.config
|
||||
val approvalKey = config.websocketToken
|
||||
if (config.websocketToken.isEmpty()) return
|
||||
|
||||
if (approvalKey.isEmpty()) {
|
||||
println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.")
|
||||
return
|
||||
}
|
||||
|
||||
// 시세 데이터는 항상 실전 서버(21000)를 권장합니다.
|
||||
val hostUrl = "ops.koreainvestment.com"
|
||||
val port = 21000
|
||||
val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
|
||||
session = this
|
||||
println("✅ 웹소켓 연결 성공")
|
||||
println("✅ 웹소켓 서버 연결 성공")
|
||||
|
||||
incoming.consumeAsFlow().collect { frame ->
|
||||
if (frame is Frame.Text) {
|
||||
@ -75,66 +63,107 @@ class KisWebSocketManager {
|
||||
}
|
||||
|
||||
private fun parseTradeData(data: String) {
|
||||
// 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
|
||||
// KIS 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
|
||||
val parts = data.split("|")
|
||||
if (parts.size > 3) {
|
||||
val rows = parts[3].split("^")
|
||||
if (rows.size > 15) {
|
||||
val newTrade = RealTimeTrade(
|
||||
time = rows[1].chunked(2).joinToString(":"), // HHMMSS -> HH:MM:SS
|
||||
price = rows[2],
|
||||
change = rows[4],
|
||||
volume = rows[12],
|
||||
type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL
|
||||
)
|
||||
if (parts.size < 4) return
|
||||
|
||||
// 메인 스레드에서 UI 상태 업데이트
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로
|
||||
if (tradeLogs.size > 30) tradeLogs.removeLast()
|
||||
val trId = parts[1]
|
||||
val body = parts[3]
|
||||
|
||||
// 현재가 및 색상 업데이트 로직 포함 가능
|
||||
currentPrice.value = newTrade.price
|
||||
}
|
||||
when (trId) {
|
||||
"H0STCNT0" -> handlePriceData(body) // [1] 실시간 시세 처리
|
||||
"H0STCNI0" -> handleExecutionData(body) // [2] 실시간 체결 통보 처리
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [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) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
private fun updatePriceWithEffect(newPrice: String) {
|
||||
val oldPrice = currentPrice.value.replace(",", "").toIntOrNull() ?: 0
|
||||
val current = newPrice.toIntOrNull() ?: 0
|
||||
|
||||
currentPrice.value = String.format("%, d", current)
|
||||
priceChangeColor.value = when {
|
||||
current > oldPrice -> Color.Red.copy(alpha = 0.2f)
|
||||
current < oldPrice -> Color.Blue.copy(alpha = 0.2f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [2] 실시간 시세 구독 (Registration)
|
||||
* tr_type = "1" (등록)
|
||||
* 개인 체결 통보 구독 (HTS ID 필요)
|
||||
*/
|
||||
suspend fun subscribeExecution(htsId: String) {
|
||||
sendRequest(htsId, trType = "1", trId = "H0STCNI0")
|
||||
println("📡 실시간 체결 통보 구독 시작: $htsId")
|
||||
}
|
||||
|
||||
suspend fun subscribeStock(stockCode: String) {
|
||||
sendRequest(stockCode, trType = "1")
|
||||
println("📡 실시간 시세 구독 시작: $stockCode")
|
||||
sendRequest(stockCode, trType = "1", trId = "H0STCNT0")
|
||||
}
|
||||
|
||||
/**
|
||||
* [3] 실시간 시세 구독 취소 (Unsubscription)
|
||||
* tr_type = "2" (해제)
|
||||
*/
|
||||
suspend fun unsubscribeStock(stockCode: String) {
|
||||
if (stockCode.isEmpty()) return
|
||||
sendRequest(stockCode, trType = "2")
|
||||
println("🚫 실시간 시세 구독 해제: $stockCode")
|
||||
if (stockCode.isNotEmpty()) sendRequest(stockCode, trType = "2", trId = "H0STCNT0")
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 요청 전송 함수
|
||||
*/
|
||||
private suspend fun sendRequest(stockCode: String, trType: String) {
|
||||
private suspend fun sendRequest(key: String, trType: String, trId: String) {
|
||||
val currentSession = session ?: return
|
||||
val config = KisSession.config
|
||||
|
||||
@ -148,8 +177,8 @@ class KisWebSocketManager {
|
||||
},
|
||||
"body": {
|
||||
"input": {
|
||||
"tr_id": "H0STCNT0",
|
||||
"tr_key": "$stockCode"
|
||||
"tr_id": "$trId",
|
||||
"tr_key": "$key"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -158,7 +187,12 @@ class KisWebSocketManager {
|
||||
try {
|
||||
currentSession.send(Frame.Text(requestJson))
|
||||
} catch (e: Exception) {
|
||||
println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}")
|
||||
println("❌ 웹소켓 요청 실패 ($trId): ${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
tradeLogs.clear()
|
||||
currentPrice.value = "0"
|
||||
}
|
||||
}
|
||||
104
src/main/kotlin/ui/ActiveTradeRow.kt
Normal file
104
src/main/kotlin/ui/ActiveTradeRow.kt
Normal file
@ -0,0 +1,104 @@
|
||||
// src/main/kotlin/ui/ActiveTradeRow.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
|
||||
@Composable
|
||||
fun ActiveTradeRow(
|
||||
item: ActiveTradeItem,
|
||||
onCancelClick: (String) -> Unit = {}, // 미체결 취소용
|
||||
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)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 2.dp)
|
||||
.clickable { onClick() },
|
||||
elevation = 2.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
backgroundColor = backgroundColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 상태 배지 (자동감시 / 미체결)
|
||||
Surface(
|
||||
color = badgeColor,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isMonitoring) "자동감시" else "미체결",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = item.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 우측 액션 영역
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
if (!isMonitoring) {
|
||||
// 미체결인 경우 취소 버튼 표시
|
||||
Button(
|
||||
onClick = { onCancelClick(item.id) },
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/main/kotlin/ui/AutoTradeSection.kt
Normal file
100
src/main/kotlin/ui/AutoTradeSection.kt
Normal file
@ -0,0 +1,100 @@
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일)
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.clickable
|
||||
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
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
import network.KisTradeService
|
||||
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt
|
||||
|
||||
@Composable
|
||||
fun AutoTradeSection(
|
||||
tradeService: KisTradeService,
|
||||
refreshTrigger: Int, // 갱신 트리거 추가
|
||||
onRefresh: () -> Unit,
|
||||
onItemSelect: (ActiveTradeItem) -> Unit
|
||||
) {
|
||||
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
|
||||
var combinedList by remember { mutableStateOf(emptyList<ActiveTradeItem>()) }
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
combinedList = monitoringItems + unfilledItems
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 강제 갱신 버튼
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn {
|
||||
items(combinedList) { item ->
|
||||
ActiveTradeRow(
|
||||
item = item,
|
||||
onCancelClick = { orderNo ->
|
||||
// tradeService.cancelOrder(orderNo, item.code) 호출 로직
|
||||
},
|
||||
onClick = {
|
||||
onItemSelect(item) // 상세 화면 전환용 콜백
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/main/kotlin/ui/AutoTradeSettingCard.kt
Normal file
80
src/main/kotlin/ui/AutoTradeSettingCard.kt
Normal file
@ -0,0 +1,80 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,22 +17,20 @@ fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val candleCount = data.size
|
||||
val candleWidth = width / candleCount
|
||||
val spacing = candleWidth * 0.2f // 캔들 사이 간격
|
||||
|
||||
// 1. 가격 범위 계산 (스케일링용)
|
||||
// 데이터가 적을 때도 일정한 너비를 유지하도록 개선
|
||||
val maxDisplayCount = 50
|
||||
val candleWidth = width / maxOf(data.size, maxDisplayCount)
|
||||
val spacing = candleWidth * 0.2f
|
||||
|
||||
// 가격 범위 계산 (여백 추가)
|
||||
val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
|
||||
val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
|
||||
val priceRange = maxPrice - minPrice
|
||||
val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 }
|
||||
val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백
|
||||
|
||||
// priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지
|
||||
fun getY(price: Double): Float {
|
||||
if (priceRange == 0.0) return height / 2f
|
||||
return (height - ((price - minPrice) / priceRange * height)).toFloat()
|
||||
}
|
||||
fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat()
|
||||
|
||||
// 루프 내부에서도 동일하게 적용
|
||||
data.forEachIndexed { index, candle ->
|
||||
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// src/main/kotlin/ui/DashboardScreen.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@ -9,6 +10,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.KisSession
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
@ -17,20 +19,76 @@ import network.KisWebSocketManager
|
||||
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) }
|
||||
|
||||
// 초기 웹소켓 연결
|
||||
LaunchedEffect(Unit) {
|
||||
// 1. 웹소켓 연결
|
||||
wsManager.connect()
|
||||
|
||||
// 2. 체결 통보 콜백 설정 (매수 성공 시 감시 시작)
|
||||
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
|
||||
if (isBuy) {
|
||||
// [매수 체결 시] DB에 감시 데이터 저장
|
||||
// 주의: targetPrice와 stopLossPrice는 이전에 설정된 값을 가져오거나
|
||||
// 임시 상태값에서 가져와야 함 (여기선 예시로 현재가의 +5%, -3% 설정)
|
||||
val execPrice = price.toDoubleOrNull() ?: 0.0
|
||||
DatabaseFactory.saveAutoTrade(
|
||||
AutoTradeItem(
|
||||
code = code,
|
||||
name = "", // 필요 시 종목명 매핑
|
||||
targetPrice = execPrice * 1.05,
|
||||
stopLossPrice = execPrice * 0.97,
|
||||
status = "MONITORING",
|
||||
isDomestic = true
|
||||
)
|
||||
)
|
||||
println("📝 매수 체결로 인한 자동 감시 등록: $code")
|
||||
} else {
|
||||
// [매도 체결 시] 감시 종료 및 DB 삭제
|
||||
DatabaseFactory.deleteAutoTrade(code)
|
||||
println("✅ 매도 체결로 인한 감시 종료: $code")
|
||||
}
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
// 3. 목표가 도달 콜백 설정 (자동 매도 실행)
|
||||
wsManager.onTargetReached = { code, price, isProfit ->
|
||||
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 손절 조건 달성"
|
||||
)
|
||||
}
|
||||
}
|
||||
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.25f).fillMaxHeight().padding(8.dp)) {
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
BalanceSection(tradeService) { code, name, isDom ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
@ -59,9 +117,20 @@ fun DashboardScreen() {
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
AutoTradeSection(
|
||||
tradeService = tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger // 트리거 전달
|
||||
) { item ->
|
||||
selectedStockCode = item.code
|
||||
selectedStockName = item.name
|
||||
isDomestic = item.isDomestic
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
MarketSection(tradeService) { code, name, isDom ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
|
||||
142
src/main/kotlin/ui/IntegratedOrderSection.kt
Normal file
142
src/main/kotlin/ui/IntegratedOrderSection.kt
Normal file
@ -0,0 +1,142 @@
|
||||
// src/main/kotlin/ui/IntegratedOrderSection.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
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
|
||||
import kotlinx.coroutines.launch
|
||||
import network.KisTradeService
|
||||
|
||||
@Composable
|
||||
fun IntegratedOrderSection(
|
||||
stockCode: String,
|
||||
currentPrice: String,
|
||||
tradeService: KisTradeService,
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
||||
|
||||
// 자동 매도 설정
|
||||
var isAutoSellEnabled by remember { mutableStateOf(false) }
|
||||
var profitRate by remember { mutableStateOf("5.0") }
|
||||
var stopLossRate by remember { mutableStateOf("-3.0") }
|
||||
|
||||
val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0
|
||||
val qty = orderQty.toDoubleOrNull() ?: 0.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,
|
||||
onValueChange = { if (it.all { c -> c.isDigit() }) orderQty = it },
|
||||
label = { Text("수량") },
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = orderPrice,
|
||||
onValueChange = { if (it.all { c -> c.isDigit() }) orderPrice = it },
|
||||
label = { Text("가격") },
|
||||
placeholder = { Text("시장가 (${currentPrice})") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 3. 자동 매도 옵션
|
||||
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)
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) { /* 자동매도 등록 로직 호출 */ }
|
||||
}
|
||||
.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 = { /* 매도 로직동일 */ },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
|
||||
) { Text("매도", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimulationColumn(title: String, items: List<String>, isHeader: Boolean) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray)
|
||||
items.forEach { text ->
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
color = if (text.contains("+")) Color(0xFFE03E2D) else if (text.contains("-")) Color(0xFF0E62CF) else Color.Black,
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/main/kotlin/ui/PeriodTrendCard.kt
Normal file
57
src/main/kotlin/ui/PeriodTrendCard.kt
Normal file
@ -0,0 +1,57 @@
|
||||
// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합)
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.geometry.Offset
|
||||
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.CandleData
|
||||
|
||||
@Composable
|
||||
fun PeriodTrendCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
val avgPrice = if (data.isEmpty()) "0"
|
||||
else String.format("%,d", data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average().toLong())
|
||||
|
||||
Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// [좌측] 라벨 및 평균가
|
||||
Column(modifier = Modifier.weight(0.4f)) {
|
||||
Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
// [우측] 간소화된 그래프 (Sparkline)
|
||||
Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) {
|
||||
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(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
start = points[i],
|
||||
end = points[i + 1],
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -54,7 +54,13 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
|
||||
Text("모의투자")
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = config.htsId,
|
||||
onValueChange = { config = config.copy(htsId = it) },
|
||||
label = { Text("HTS ID (실시간 체결 통보용)") },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") }
|
||||
)
|
||||
// 실전 3종 입력
|
||||
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(value = config.realAccountNo, onValueChange = {
|
||||
|
||||
@ -22,6 +22,7 @@ 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.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
@ -41,10 +42,29 @@ fun StockDetailSection(
|
||||
tradeService: KisTradeService,
|
||||
wsManager: KisWebSocketManager
|
||||
) {
|
||||
|
||||
var openPrice by remember { mutableStateOf("0") }
|
||||
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var resultMessage by remember { mutableStateOf("") }
|
||||
var isSuccess by remember { mutableStateOf(true) }
|
||||
var daySummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var weekSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var monthSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var yearSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
|
||||
val todayOpen = remember(daySummary) {
|
||||
daySummary.lastOrNull()?.stck_oprc ?: "0"
|
||||
}
|
||||
val previousClose = remember(daySummary) {
|
||||
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_clpr else "0"
|
||||
}
|
||||
|
||||
fun calculateAvg(data: List<CandleData>): String {
|
||||
if (data.isEmpty()) return "0"
|
||||
val avg = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }.average()
|
||||
return String.format("%,d", avg.toLong())
|
||||
}
|
||||
|
||||
// 이전 종목 코드를 기억하기 위한 상태
|
||||
var previousCode by remember { mutableStateOf("") }
|
||||
@ -59,49 +79,103 @@ fun StockDetailSection(
|
||||
if (previousCode.isNotEmpty()) {
|
||||
wsManager.unsubscribeStock(previousCode)
|
||||
}
|
||||
wsManager.clearData()
|
||||
wsManager.subscribeStock(stockCode)
|
||||
previousCode = stockCode
|
||||
|
||||
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
||||
tradeService.fetchChartData(stockCode, isDomestic)
|
||||
.onSuccess { data ->
|
||||
println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
chartData = data
|
||||
}
|
||||
.onFailure { error ->
|
||||
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
launch {tradeService.fetchChartData(stockCode, isDomestic)
|
||||
.onSuccess { data ->
|
||||
println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
chartData = data
|
||||
}
|
||||
.onFailure { error ->
|
||||
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}}
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { daySummary = it.takeLast(7) } } // 최근 7일
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { weekSummary = it.takeLast(4) } } // 최근 4주
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
|
||||
monthSummary = it.takeLast(6) // 최근 6개월
|
||||
yearSummary = it.takeLast(36) // 최근 3년
|
||||
} }
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
|
||||
|
||||
LaunchedEffect(latestPrice) {
|
||||
println("latestPrice $latestPrice")
|
||||
|
||||
if (chartData.isNotEmpty() && latestPrice != "0") {
|
||||
|
||||
// 마지막 캔들 정보 업데이트
|
||||
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
|
||||
val lastCandle = chartData.last()
|
||||
|
||||
val updatedCandle = lastCandle.copy(
|
||||
stck_clpr = latestPrice,
|
||||
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
)
|
||||
// 현재 시간(분 단위) 확인
|
||||
val currentMinute = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HHmm00"))
|
||||
|
||||
chartData = chartData.dropLast(1) + updatedCandle
|
||||
println("chartData.size $chartData.size")
|
||||
if (lastCandle.stck_bsop_date != currentMinute) {
|
||||
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||
val newCandle = CandleData(
|
||||
stck_bsop_date = currentMinute,
|
||||
stck_oprc = latestPrice,
|
||||
stck_hgpr = latestPrice,
|
||||
stck_lwpr = latestPrice,
|
||||
stck_clpr = latestPrice,
|
||||
acml_vol = "0"
|
||||
)
|
||||
// 최대 100개까지만 유지하여 성능 최적화
|
||||
chartData = (chartData + newCandle).takeLast(100)
|
||||
} else {
|
||||
// 같은 분 내에서는 기존 마지막 캔들만 업데이트
|
||||
val updatedCandle = lastCandle.copy(
|
||||
stck_clpr = latestPrice,
|
||||
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
)
|
||||
chartData = chartData.dropLast(1) + updatedCandle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// [상단] 종목명 및 상태 메시지
|
||||
StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StockHeader(
|
||||
name = stockName,
|
||||
code = stockCode,
|
||||
isDomestic = isDomestic,
|
||||
previousClose = previousClose,
|
||||
openPrice = openPrice,
|
||||
resultMessage = resultMessage,
|
||||
isSuccess = isSuccess
|
||||
)
|
||||
|
||||
// 실시간 가격 표시 (WebSocket 데이터)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "${wsManager.currentPrice.value} 원",
|
||||
style = MaterialTheme.typography.h4,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (wsManager.currentPrice.value.contains("-")) Color.Blue else Color.Red
|
||||
)
|
||||
Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
// 통합된 트렌드 카드 배치
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PeriodTrendCard("7일", daySummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("4주", weekSummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("3년", yearSummary, Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
// [중앙] 캔들 차트 (Card 내부)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||
@ -136,14 +210,27 @@ fun StockDetailSection(
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 주문 섹션 (인자 간소화)
|
||||
OrderSection(
|
||||
stockCode = stockCode,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
)
|
||||
Column(modifier = Modifier.weight(0.6f)) {
|
||||
IntegratedOrderSection(
|
||||
stockCode = stockCode,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
tradeService = tradeService,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -17,64 +18,52 @@ fun StockHeader(
|
||||
name: String,
|
||||
code: String,
|
||||
isDomestic: Boolean,
|
||||
previousClose: String, // 추가: 전일 종가
|
||||
openPrice: String, // 추가: 금일 시가
|
||||
resultMessage: String,
|
||||
isSuccess: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시)
|
||||
Column(modifier = Modifier.wrapContentWidth()) {
|
||||
// [1] 알림 메시지 영역 (기존 동일)
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = resultMessage,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
// [2] 종목명 및 국가 배지 영역
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
) {
|
||||
// 국가 구분 배지
|
||||
// [2] 종목명 및 정보
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isDomestic) "국내" else "해외",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 종목 이름
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = "($code)", color = Color.Gray)
|
||||
}
|
||||
|
||||
// 종목 코드
|
||||
Text(
|
||||
text = "($code)",
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = Color.Gray
|
||||
)
|
||||
// [3] 전일 종가 및 시가 정보 행 추가
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
PriceSummaryItem("전일 종가", previousClose)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
PriceSummaryItem("금일 시가", openPrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PriceSummaryItem(label: String, price: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = label, fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price
|
||||
Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
53
src/main/kotlin/ui/SummaryGraphCard.kt
Normal file
53
src/main/kotlin/ui/SummaryGraphCard.kt
Normal file
@ -0,0 +1,53 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user