....
This commit is contained in:
parent
2bb94e2856
commit
bdc268e325
@ -45,6 +45,7 @@ dependencies {
|
|||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ fun main() = application {
|
|||||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||||
isSimulation = it[ConfigTable.isSimulation],
|
isSimulation = it[ConfigTable.isSimulation],
|
||||||
|
htsId = it[ConfigTable.htsId],
|
||||||
modelPath = it[ConfigTable.modelPath]
|
modelPath = it[ConfigTable.modelPath]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import model.AppConfig
|
import model.AppConfig
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||||
import org.jetbrains.exposed.sql.javatime.datetime
|
import org.jetbrains.exposed.sql.javatime.datetime
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -16,37 +17,110 @@ object ConfigTable : Table("app_config") {
|
|||||||
val vtsAccountNo = varchar("vts_account_no", 20).default("")
|
val vtsAccountNo = varchar("vts_account_no", 20).default("")
|
||||||
val isSimulation = bool("is_simulation").default(true)
|
val isSimulation = bool("is_simulation").default(true)
|
||||||
val modelPath = varchar("model_path", 512).default("")
|
val modelPath = varchar("model_path", 512).default("")
|
||||||
|
val htsId = varchar("hts_id", 50).default("") // HTS ID 컬럼 추가
|
||||||
override val primaryKey = PrimaryKey(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") {
|
object TradeLogTable : Table("trade_logs") {
|
||||||
val id = long("id").autoIncrement()
|
val id = long("id").autoIncrement()
|
||||||
val stockCode = varchar("stock_code", 20) // 종목코드
|
val stockCode = varchar("stock_code", 20)
|
||||||
val stockName = varchar("stock_name", 50) // 종목명
|
val stockName = varchar("stock_name", 50)
|
||||||
val tradeType = varchar("trade_type", 10) // 매수/매도
|
val tradeType = varchar("trade_type", 10)
|
||||||
val price = double("price") // 체결가
|
val price = double("price")
|
||||||
val quantity = integer("quantity") // 수량
|
val quantity = integer("quantity")
|
||||||
val timestamp = datetime("timestamp") // 거래 시간
|
val timestamp = datetime("timestamp")
|
||||||
val logMessage = text("log_message") // Ollama의 판단 근거 등 상세 정보
|
val logMessage = text("log_message")
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
object DatabaseFactory {
|
object DatabaseFactory {
|
||||||
fun init() {
|
fun init() {
|
||||||
|
val dbPath = File("db/autotrade_db").absolutePath
|
||||||
val dbPath =File("db/autotrade_db").absolutePath
|
|
||||||
// 드라이버를 org.h2.Driver로 설정
|
|
||||||
Database.connect(
|
Database.connect(
|
||||||
"jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;",
|
"jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;",
|
||||||
driver = "org.h2.Driver"
|
driver = "org.h2.Driver"
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction {
|
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) {
|
fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) {
|
||||||
transaction {
|
transaction {
|
||||||
TradeLogTable.insert {
|
TradeLogTable.insert {
|
||||||
@ -61,28 +135,26 @@ object DatabaseFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findConfigByAccount(accountNo: String): AppConfig? {
|
fun findConfigByAccount(accountNo: String): AppConfig? = transaction {
|
||||||
return transaction {
|
ConfigTable.select {
|
||||||
ConfigTable.select {
|
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
|
||||||
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
|
}.lastOrNull()?.let {
|
||||||
}.lastOrNull()?.let {
|
AppConfig(
|
||||||
AppConfig(
|
realAppKey = it[ConfigTable.realAppKey],
|
||||||
realAppKey = it[ConfigTable.realAppKey],
|
realSecretKey = it[ConfigTable.realSecretKey],
|
||||||
realSecretKey = it[ConfigTable.realSecretKey],
|
realAccountNo = it[ConfigTable.realAccountNo],
|
||||||
realAccountNo = it[ConfigTable.realAccountNo],
|
vtsAppKey = it[ConfigTable.vtsAppKey],
|
||||||
vtsAppKey = it[ConfigTable.vtsAppKey],
|
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
||||||
vtsSecretKey = it[ConfigTable.vtsSecretKey],
|
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
||||||
vtsAccountNo = it[ConfigTable.vtsAccountNo],
|
isSimulation = it[ConfigTable.isSimulation],
|
||||||
isSimulation = it[ConfigTable.isSimulation],
|
htsId = it[ConfigTable.htsId], // htsId 로드
|
||||||
modelPath = it[ConfigTable.modelPath]
|
modelPath = it[ConfigTable.modelPath]
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveConfig(config: AppConfig) {
|
fun saveConfig(config: AppConfig) {
|
||||||
transaction {
|
transaction {
|
||||||
// 기존 설정을 모두 지우고 최신 설정 하나만 유지
|
|
||||||
ConfigTable.deleteAll()
|
ConfigTable.deleteAll()
|
||||||
ConfigTable.insert {
|
ConfigTable.insert {
|
||||||
it[realAppKey] = config.realAppKey
|
it[realAppKey] = config.realAppKey
|
||||||
@ -92,9 +164,21 @@ object DatabaseFactory {
|
|||||||
it[realAccountNo] = config.realAccountNo
|
it[realAccountNo] = config.realAccountNo
|
||||||
it[vtsAccountNo] = config.vtsAccountNo
|
it[vtsAccountNo] = config.vtsAccountNo
|
||||||
it[isSimulation] = config.isSimulation
|
it[isSimulation] = config.isSimulation
|
||||||
|
it[htsId] = config.htsId
|
||||||
it[modelPath] = config.modelPath
|
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 tradeToken: String = "",
|
||||||
var tradeTokenExpiredAt: LocalDateTime? = null,
|
var tradeTokenExpiredAt: LocalDateTime? = null,
|
||||||
|
|
||||||
|
val htsId: String = "",
|
||||||
|
|
||||||
var websocketToken: String = "",
|
var websocketToken: String = "",
|
||||||
val isSimulation: Boolean = true,
|
val isSimulation: Boolean = true,
|
||||||
val modelPath: String = "") {
|
val modelPath: String = "") {
|
||||||
|
|||||||
@ -65,14 +65,15 @@ data class RankingStock(
|
|||||||
val hts_kor_alph_nm: String = "", // 종목명
|
val hts_kor_alph_nm: String = "", // 종목명
|
||||||
val mkrtc_objt_iscd: String = "", // 종목코드
|
val mkrtc_objt_iscd: String = "", // 종목코드
|
||||||
val mksc_shrn_iscd: String = "", // 종목코드
|
val mksc_shrn_iscd: String = "", // 종목코드
|
||||||
|
val stck_shrn_iscd: String = "", // 종목코드
|
||||||
val stck_prpr: String = "0", // 현재가
|
val stck_prpr: String = "0", // 현재가
|
||||||
val prdy_ctrt: String = "0.0", // 등락률
|
val prdy_ctrt: String = "0.0", // 등락률
|
||||||
val mrkt_div_cls_code : String = "J",
|
val mrkt_div_cls_code : String = "J",
|
||||||
) {
|
) {
|
||||||
val name : String
|
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
|
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
|
@Serializable
|
||||||
data class OverseasRankingResponse(
|
data class OverseasRankingResponse(
|
||||||
@ -116,3 +117,37 @@ data class UnifiedBalance(
|
|||||||
val totalProfitRate: String, // 총 수익률
|
val totalProfitRate: String, // 총 수익률
|
||||||
val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트
|
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.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import model.*
|
import model.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
class KisTradeService {
|
class KisTradeService {
|
||||||
private val client = HttpClient(CIO) {
|
private val client = HttpClient(CIO) {
|
||||||
@ -173,6 +176,66 @@ class KisTradeService {
|
|||||||
} catch (e: Exception) { Result.failure(e) }
|
} 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(
|
suspend fun postOrder(
|
||||||
stockCode: String,
|
stockCode: String,
|
||||||
qty: String,
|
qty: String,
|
||||||
price: String, // "0" 이면 시장가
|
price: String,
|
||||||
isBuy: Boolean
|
isBuy: Boolean
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
|
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
|
||||||
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
|
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 {
|
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) "TTTC0802U" else "TTTC0801U"
|
||||||
!isDomestic && config.isSimulation -> if (isBuy) "VTTT3001U" else "VTTT3002U"
|
else -> if (isBuy) "TTTS3002U" else "TTTS3001U"
|
||||||
else -> if (isBuy) "TTTS3001U" else "TTTS3002U"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
|
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("authorization", "Bearer ${config.tradeToken}")
|
||||||
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
|
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
|
||||||
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
||||||
@ -242,15 +381,17 @@ class KisTradeService {
|
|||||||
setBody(mapOf(
|
setBody(mapOf(
|
||||||
"CANO" to config.accountNo.take(8),
|
"CANO" to config.accountNo.take(8),
|
||||||
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
|
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
|
||||||
"PDNO" to stockCode,
|
"KRX_FWDG_ORD_ORGNO" to "", // 공란 혹은 지점번호
|
||||||
"ORD_DVSN" to if (price == "0") "01" else "00",
|
"ORGN_ORD_NO" to orgNo, // 취소할 원주문번호
|
||||||
"ORD_QTY" to qty,
|
"RVSE_CNCL_DVSN" to "02", // 01: 정정, 02: 취소
|
||||||
"ORD_UNPR" to price
|
"ORD_DVSN" to "00", // 지정가
|
||||||
|
"ORD_QTY" to "0", // 0이면 전량 취소
|
||||||
|
"ORD_UNPR" to "0"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
val body = response.body<Map<String, Any>>()
|
val body = response.body<JsonObject>()
|
||||||
if (body["rt_cd"] == "0") Result.success("✅ 주문 성공: ${body["msg1"]}")
|
if (body["rt_cd"]?.jsonPrimitive?.content == "0") Result.success("취소 완료")
|
||||||
else Result.failure(Exception("${body["msg1"]}"))
|
else Result.failure(Exception(body["msg1"]?.jsonPrimitive?.content))
|
||||||
} catch (e: Exception) { Result.failure(e) }
|
} catch (e: Exception) { Result.failure(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +425,12 @@ class KisTradeService {
|
|||||||
val path = if (isDomestic)
|
val path = if (isDomestic)
|
||||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||||
else "/uapi/overseas-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 {
|
return try {
|
||||||
val response = client.get("$prodUrl$path") {
|
val response = client.get("$prodUrl$path") {
|
||||||
header("authorization", "Bearer ${config.marketToken}")
|
header("authorization", "Bearer ${config.marketToken}")
|
||||||
@ -297,7 +443,7 @@ class KisTradeService {
|
|||||||
parameter("FID_ETC_CLS_CODE", "")
|
parameter("FID_ETC_CLS_CODE", "")
|
||||||
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
||||||
parameter("FID_INPUT_ISCD", stockCode)
|
parameter("FID_INPUT_ISCD", stockCode)
|
||||||
parameter("FID_INPUT_HOUR_1", "153000") // 장 마감 시간까지
|
parameter("FID_INPUT_HOUR_1", searchTime) // 장 마감 시간까지
|
||||||
parameter("FID_PW_DATA_INCU_YN", "Y") // 전일 데이터 포함 여부
|
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.*
|
||||||
import io.ktor.client.engine.cio.*
|
import io.ktor.client.engine.cio.*
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
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.client.plugins.websocket.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.websocket.*
|
import io.ktor.websocket.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.consumeAsFlow
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import model.AppConfig
|
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import model.RealTimeTrade
|
import model.RealTimeTrade
|
||||||
import model.TradeType
|
import model.TradeType
|
||||||
|
|
||||||
class KisWebSocketManager {
|
class KisWebSocketManager {
|
||||||
private val client = HttpClient(CIO) {
|
private val client = HttpClient(CIO) {
|
||||||
install(WebSockets) {
|
install(WebSockets) { pingInterval = 20_000 }
|
||||||
pingInterval = 20_000
|
|
||||||
}
|
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = 15_000
|
requestTimeoutMillis = 15_000
|
||||||
connectTimeoutMillis = 15_000
|
connectTimeoutMillis = 15_000
|
||||||
socketTimeoutMillis = 15_000
|
|
||||||
}
|
|
||||||
install(Logging) {
|
|
||||||
logger = Logger.DEFAULT
|
|
||||||
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var session: DefaultClientWebSocketSession? = null
|
private var session: DefaultClientWebSocketSession? = null
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + Job())
|
private val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||||
|
|
||||||
// UI 관찰 상태값
|
// UI 상태값
|
||||||
val currentPrice = mutableStateOf("0")
|
val currentPrice = mutableStateOf("0")
|
||||||
val priceChangeColor = mutableStateOf(Color.Transparent)
|
|
||||||
val tradeLogs = mutableStateListOf<RealTimeTrade>()
|
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() {
|
suspend fun connect() {
|
||||||
val config = KisSession.config
|
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 hostUrl = "ops.koreainvestment.com"
|
||||||
val port = 21000
|
val port = 21000 // 실전: 21000, 모의: 21000 (동일하나 TR_ID 등에 따라 다름)
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
|
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
|
||||||
session = this
|
session = this
|
||||||
println("✅ 웹소켓 연결 성공")
|
println("✅ 웹소켓 서버 연결 성공")
|
||||||
|
|
||||||
incoming.consumeAsFlow().collect { frame ->
|
incoming.consumeAsFlow().collect { frame ->
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
@ -75,66 +63,107 @@ class KisWebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseTradeData(data: String) {
|
private fun parseTradeData(data: String) {
|
||||||
// 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
|
// KIS 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
|
||||||
val parts = data.split("|")
|
val parts = data.split("|")
|
||||||
if (parts.size > 3) {
|
if (parts.size < 4) return
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
// 메인 스레드에서 UI 상태 업데이트
|
val trId = parts[1]
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
val body = parts[3]
|
||||||
tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로
|
|
||||||
if (tradeLogs.size > 30) tradeLogs.removeLast()
|
|
||||||
|
|
||||||
// 현재가 및 색상 업데이트 로직 포함 가능
|
when (trId) {
|
||||||
currentPrice.value = newTrade.price
|
"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)
|
* 개인 체결 통보 구독 (HTS ID 필요)
|
||||||
* tr_type = "1" (등록)
|
|
||||||
*/
|
*/
|
||||||
|
suspend fun subscribeExecution(htsId: String) {
|
||||||
|
sendRequest(htsId, trType = "1", trId = "H0STCNI0")
|
||||||
|
println("📡 실시간 체결 통보 구독 시작: $htsId")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun subscribeStock(stockCode: String) {
|
suspend fun subscribeStock(stockCode: String) {
|
||||||
sendRequest(stockCode, trType = "1")
|
sendRequest(stockCode, trType = "1", trId = "H0STCNT0")
|
||||||
println("📡 실시간 시세 구독 시작: $stockCode")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [3] 실시간 시세 구독 취소 (Unsubscription)
|
|
||||||
* tr_type = "2" (해제)
|
|
||||||
*/
|
|
||||||
suspend fun unsubscribeStock(stockCode: String) {
|
suspend fun unsubscribeStock(stockCode: String) {
|
||||||
if (stockCode.isEmpty()) return
|
if (stockCode.isNotEmpty()) sendRequest(stockCode, trType = "2", trId = "H0STCNT0")
|
||||||
sendRequest(stockCode, trType = "2")
|
|
||||||
println("🚫 실시간 시세 구독 해제: $stockCode")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private suspend fun sendRequest(key: String, trType: String, trId: String) {
|
||||||
* 공통 요청 전송 함수
|
|
||||||
*/
|
|
||||||
private suspend fun sendRequest(stockCode: String, trType: String) {
|
|
||||||
val currentSession = session ?: return
|
val currentSession = session ?: return
|
||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
|
|
||||||
@ -148,8 +177,8 @@ class KisWebSocketManager {
|
|||||||
},
|
},
|
||||||
"body": {
|
"body": {
|
||||||
"input": {
|
"input": {
|
||||||
"tr_id": "H0STCNT0",
|
"tr_id": "$trId",
|
||||||
"tr_key": "$stockCode"
|
"tr_key": "$key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -158,7 +187,12 @@ class KisWebSocketManager {
|
|||||||
try {
|
try {
|
||||||
currentSession.send(Frame.Text(requestJson))
|
currentSession.send(Frame.Text(requestJson))
|
||||||
} catch (e: Exception) {
|
} 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()) {
|
Canvas(modifier = modifier.fillMaxSize()) {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
val height = size.height
|
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 maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
|
||||||
val minPrice = data.minOf { it.stck_lwpr.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 = (height - ((price - basePrice) / priceRange * height)).toFloat()
|
||||||
fun getY(price: Double): Float {
|
|
||||||
if (priceRange == 0.0) return height / 2f
|
|
||||||
return (height - ((price - minPrice) / priceRange * height)).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 루프 내부에서도 동일하게 적용
|
|
||||||
data.forEachIndexed { index, candle ->
|
data.forEachIndexed { index, candle ->
|
||||||
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||||
val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
|
val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// src/main/kotlin/ui/DashboardScreen.kt
|
// src/main/kotlin/ui/DashboardScreen.kt
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
|
import AutoTradeItem
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@ -9,6 +10,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
import network.KisWebSocketManager
|
import network.KisWebSocketManager
|
||||||
@ -17,20 +19,76 @@ import network.KisWebSocketManager
|
|||||||
fun DashboardScreen() {
|
fun DashboardScreen() {
|
||||||
val tradeService = remember { KisTradeService() }
|
val tradeService = remember { KisTradeService() }
|
||||||
val wsManager = remember { KisWebSocketManager() }
|
val wsManager = remember { KisWebSocketManager() }
|
||||||
|
val config = KisSession.config
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// 데이터 갱신을 위한 트리거 상태
|
||||||
|
var refreshTrigger by remember { mutableStateOf(0) }
|
||||||
// 전역 상태: 현재 선택된 종목 정보
|
// 전역 상태: 현재 선택된 종목 정보
|
||||||
var selectedStockCode by remember { mutableStateOf("") }
|
var selectedStockCode by remember { mutableStateOf("") }
|
||||||
var selectedStockName by remember { mutableStateOf("") }
|
var selectedStockName by remember { mutableStateOf("") }
|
||||||
var isDomestic by remember { mutableStateOf(true) }
|
var isDomestic by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
// 초기 웹소켓 연결
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// 1. 웹소켓 연결
|
||||||
wsManager.connect()
|
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))) {
|
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||||
// [좌측 25%] 내 자산 및 통합 잔고
|
// [좌측 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 ->
|
BalanceSection(tradeService) { code, name, isDom ->
|
||||||
selectedStockCode = code
|
selectedStockCode = code
|
||||||
selectedStockName = name
|
selectedStockName = name
|
||||||
@ -59,9 +117,20 @@ fun DashboardScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VerticalDivider()
|
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 (실전 데이터)
|
// [우측 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 ->
|
MarketSection(tradeService) { code, name, isDom ->
|
||||||
selectedStockCode = code
|
selectedStockCode = code
|
||||||
selectedStockName = name
|
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("모의투자")
|
Text("모의투자")
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(vertical = 12.dp))
|
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종 입력
|
// 실전 3종 입력
|
||||||
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
||||||
OutlinedTextField(value = config.realAccountNo, onValueChange = {
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import model.AppConfig
|
import model.AppConfig
|
||||||
@ -41,10 +42,29 @@ fun StockDetailSection(
|
|||||||
tradeService: KisTradeService,
|
tradeService: KisTradeService,
|
||||||
wsManager: KisWebSocketManager
|
wsManager: KisWebSocketManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
var openPrice by remember { mutableStateOf("0") }
|
||||||
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
var resultMessage by remember { mutableStateOf("") }
|
var resultMessage by remember { mutableStateOf("") }
|
||||||
var isSuccess by remember { mutableStateOf(true) }
|
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("") }
|
var previousCode by remember { mutableStateOf("") }
|
||||||
@ -59,49 +79,103 @@ fun StockDetailSection(
|
|||||||
if (previousCode.isNotEmpty()) {
|
if (previousCode.isNotEmpty()) {
|
||||||
wsManager.unsubscribeStock(previousCode)
|
wsManager.unsubscribeStock(previousCode)
|
||||||
}
|
}
|
||||||
|
wsManager.clearData()
|
||||||
wsManager.subscribeStock(stockCode)
|
wsManager.subscribeStock(stockCode)
|
||||||
previousCode = stockCode
|
previousCode = stockCode
|
||||||
|
|
||||||
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
// 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
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
|
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
|
||||||
|
|
||||||
LaunchedEffect(latestPrice) {
|
LaunchedEffect(latestPrice) {
|
||||||
println("latestPrice $latestPrice")
|
|
||||||
|
|
||||||
if (chartData.isNotEmpty() && latestPrice != "0") {
|
if (chartData.isNotEmpty() && latestPrice != "0") {
|
||||||
|
|
||||||
// 마지막 캔들 정보 업데이트
|
|
||||||
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
|
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
|
||||||
val lastCandle = chartData.last()
|
val lastCandle = chartData.last()
|
||||||
|
|
||||||
val updatedCandle = lastCandle.copy(
|
// 현재 시간(분 단위) 확인
|
||||||
stck_clpr = latestPrice,
|
val currentMinute = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HHmm00"))
|
||||||
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
|
if (lastCandle.stck_bsop_date != currentMinute) {
|
||||||
println("chartData.size $chartData.size")
|
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||||
|
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)) {
|
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 내부)
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||||
@ -136,14 +210,27 @@ fun StockDetailSection(
|
|||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
// 주문 섹션 (인자 간소화)
|
// 주문 섹션 (인자 간소화)
|
||||||
OrderSection(
|
Column(modifier = Modifier.weight(0.6f)) {
|
||||||
stockCode = stockCode,
|
IntegratedOrderSection(
|
||||||
currentPrice = wsManager.currentPrice.value,
|
stockCode = stockCode,
|
||||||
onOrderResult = { msg, success ->
|
currentPrice = wsManager.currentPrice.value,
|
||||||
resultMessage = msg
|
tradeService = tradeService,
|
||||||
isSuccess = success
|
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
|
package ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -17,64 +18,52 @@ fun StockHeader(
|
|||||||
name: String,
|
name: String,
|
||||||
code: String,
|
code: String,
|
||||||
isDomestic: Boolean,
|
isDomestic: Boolean,
|
||||||
|
previousClose: String, // 추가: 전일 종가
|
||||||
|
openPrice: String, // 추가: 금일 시가
|
||||||
resultMessage: String,
|
resultMessage: String,
|
||||||
isSuccess: Boolean
|
isSuccess: Boolean
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.wrapContentWidth()) {
|
||||||
// [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시)
|
// [1] 알림 메시지 영역 (기존 동일)
|
||||||
if (resultMessage.isNotEmpty()) {
|
if (resultMessage.isNotEmpty()) {
|
||||||
Surface(
|
Surface(
|
||||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강
|
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
shape = RoundedCornerShape(4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
|
||||||
text = resultMessage,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [2] 종목명 및 국가 배지 영역
|
// [2] 종목명 및 정보
|
||||||
Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
// 국가 구분 배지
|
|
||||||
Surface(
|
Surface(
|
||||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑
|
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
shape = RoundedCornerShape(4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
|
||||||
text = if (isDomestic) "국내" else "해외",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.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))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(text = "($code)", color = Color.Gray)
|
||||||
|
}
|
||||||
|
|
||||||
// 종목 코드
|
// [3] 전일 종가 및 시가 정보 행 추가
|
||||||
Text(
|
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||||
text = "($code)",
|
PriceSummaryItem("전일 종가", previousClose)
|
||||||
style = MaterialTheme.typography.body1,
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
color = Color.Gray
|
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