This commit is contained in:
lunaticbum 2026-01-14 15:42:26 +09:00
parent 2bb94e2856
commit bdc268e325
18 changed files with 1193 additions and 205 deletions

View File

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

View File

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

View File

@ -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,8 +135,7 @@ object DatabaseFactory {
}
}
fun findConfigByAccount(accountNo: String): AppConfig? {
return transaction {
fun findConfigByAccount(accountNo: String): AppConfig? = transaction {
ConfigTable.select {
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
}.lastOrNull()?.let {
@ -74,15 +147,14 @@ object DatabaseFactory {
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
)

View File

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

View File

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

View File

@ -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") // 전일 데이터 포함 여부
}

View File

@ -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) {
if (parts.size < 4) return
val trId = parts[1]
val body = parts[3]
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(":"), // HHMMSS -> HH:MM:SS
price = rows[2],
time = rows[1].chunked(2).joinToString(":"),
price = priceStr,
change = rows[4],
volume = rows[12],
type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL
)
// 메인 스레드에서 UI 상태 업데이트
CoroutineScope(Dispatchers.Main).launch {
tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로
scope.launch(Dispatchers.Main) {
tradeLogs.add(0, newTrade)
if (tradeLogs.size > 30) tradeLogs.removeLast()
currentPrice.value = String.format("%,d", currentPriceInt)
// 현재가 및 색상 업데이트 로직 포함 가능
currentPrice.value = newTrade.price
}
}
}
}
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
// 실시간 감시 엔진 작동
checkAutoTradeTargets(stockCode, currentPriceInt.toDouble())
}
}
/**
* [2] 실시간 시세 구독 (Registration)
* tr_type = "1" (등록)
* [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) }
}
}
}
/**
* 개인 체결 통보 구독 (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"
}
}

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

View 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) // 상세 화면 전환용 콜백
}
)
}
}
}
}

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

View File

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

View File

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

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

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

View File

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

View File

@ -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,11 +79,14 @@ fun StockDetailSection(
if (previousCode.isNotEmpty()) {
wsManager.unsubscribeStock(previousCode)
}
wsManager.clearData()
wsManager.subscribeStock(stockCode)
previousCode = stockCode
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
tradeService.fetchChartData(stockCode, isDomestic)
coroutineScope {
launch {tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력
chartData = data
@ -71,37 +94,88 @@ fun StockDetailSection(
.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 currentMinute = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HHmm00"))
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
println("chartData.size $chartData.size")
}
}
}
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,9 +210,11 @@ fun StockDetailSection(
Spacer(modifier = Modifier.width(12.dp))
// 주문 섹션 (인자 간소화)
OrderSection(
Column(modifier = Modifier.weight(0.6f)) {
IntegratedOrderSection(
stockCode = stockCode,
currentPrice = wsManager.currentPrice.value,
tradeService = tradeService,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
@ -146,4 +222,15 @@ fun StockDetailSection(
)
}
}
}
}
@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)
}
}
}

View File

@ -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)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "($code)", color = Color.Gray)
}
Spacer(modifier = Modifier.width(8.dp))
// 종목 이름
Text(
text = name,
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(6.dp))
// 종목 코드
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)
}
}

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