import androidx.compose.runtime.mutableStateListOf import kotlinx.serialization.Serializable import model.AppConfig import network.TradingDecision 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 import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter object TradeStatus { const val PENDING_BUY = "PENDING_BUY" // 매수 주문 중 const val MONITORING = "MONITORING" // 매수 체결 후 감시 중 const val SELLING = "SELLING" // 손절/익절 매도 주문 중 const val EXPIRED = "EXPIRED" // 서버와 불일치 (유저 판단 대기) const val COMPLETED = "COMPLETED" // 거래 종료 } // 1. 앱 설정 테이블 object ConfigTable : Table("app_config") { val id = integer("id").autoIncrement() val realAppKey = varchar("real_app_key", 255).default("") val realSecretKey = varchar("real_secret_key", 255).default("") val realAccountNo = varchar("real_account_no", 20).default("") val vtsAppKey = varchar("vts_app_key", 255).default("") val vtsSecretKey = varchar("vts_secret_key", 255).default("") val vtsAccountNo = varchar("vts_account_no", 20).default("") val nAppKey = varchar("naver_app_key", 255).default("") val nSecretKey = varchar("naver_secret_key", 255).default("") val dAppKey = varchar("dart_api_key", 255).default("") val isSimulation = bool("is_simulation").default(false) val modelPath = varchar("model_path", 512).default("") val embedModelPath = varchar("embed_model_path", 512).default("") val htsId = varchar("hts_id", 50).default("") // HTS ID 컬럼 추가 val fees_and_taxrate = double("fees_and_taxrate").default( 0.33) val minimum_net_profit = double("minimum_net_profit").default( 0.5) val buy_weight = double("buy_weight").default( 2.0) val max_budget = double("max_budget").default( 80000.0) val max_price = double("max_price").default( 40000.0) val min_price = double("min_price").default( 800.0) val min_purchase_score = double("min_purchase_score").default( 65.0) val sell_profit = double("sell_profit").default( 1.0) val grade_5_buy = integer("grade_5_buy").default(0) val grade_4_buy = integer("grade_4_buy").default(1) val grade_3_buy = integer("grade_3_buy").default(1) val grade_2_buy = integer("grade_2_buy").default(2) val grade_1_buy = integer("grade_1_buy").default(3) val grade_5_profit = double("grade_5_profit").default(1.8) val grade_4_profit = double("grade_4_profit").default(1.3) val grade_3_profit = double("grade_3_profit").default(0.9) val grade_2_profit = double("grade_2_profit").default(0.7) val grade_1_profit = double("grade_1_profit").default(0.5) val grade_5_allocationrate = double("grade_5_allocationrate").default(1.0) val grade_4_allocationrate = double("grade_4_allocationrate").default(0.8) val grade_3_allocationrate = double("grade_3_allocationrate").default(0.6) val grade_2_allocationrate = double("grade_2_allocationrate").default(0.4) val grade_1_allocationrate = double("grade_1_allocationrate").default(0.3) val take_profit = bool("take_profit").default(false) val stop_Loss = bool("stop_Loss").default(false) val loss_minrate = double("loss_minrate").default(3.5) val loss_maxrate = double("loss_maxrate").default(10.0) val loss_max_money = double("loss_max_money").default(10000.0) val max_count = integer("max_count").default(20) override val primaryKey = PrimaryKey(id) } // 2. 자동매매 감시 테이블 object AutoTradeTable : Table("auto_trades") { val id = integer("id").autoIncrement() val stockCode = varchar("stock_code", 20) val stockName = varchar("stock_name", 100) val quantity = integer("quantity").default(0) val profitRate = double("profit_rate").default(0.0) val stopLossRate = double("stop_loss_rate").default(0.0) val targetPrice = double("target_price").default(0.0) val stopLossPrice = double("stop_loss_price").default(0.0) val orderNo = varchar("order_no", 50).uniqueIndex() val status = varchar("status", 20).default("PENDING_BUY") val isDomestic = bool("is_domestic").default(true) override val primaryKey = PrimaryKey(id) } // 3. 거래 내역 테이블 object TradeLogTable : Table("trade_logs") { val id = long("id").autoIncrement() 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 HolidayTable : Table("holiday_cache") { val bassDt = varchar("bass_dt", 8) // YYYYMMDD val isHoliday = bool("is_holiday") override val primaryKey = PrimaryKey(bassDt) } object DatabaseFactory { fun init() { val dbPath = File("db/autotrade_db").absolutePath Database.connect( "jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;", driver = "org.h2.Driver" ) transaction { // 테이블 생성 (AutoTradeTable 포함) SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,HolidayTable) } } fun saveHoliday(date: String, holiday: Boolean) = transaction { HolidayTable.replace { it[bassDt] = date it[isHoliday] = holiday } } // 특정 날짜의 휴장 여부 조회 fun getHoliday(date: String): Boolean? = transaction { HolidayTable.select { HolidayTable.bassDt eq date } .map { it[HolidayTable.isHoliday] } .singleOrNull() } /** * 새로운 자동매매 건 등록 (주로 PENDING_BUY 상태로 시작) */ fun saveAutoTrade(item: AutoTradeItem) = transaction { AutoTradeTable.insert { it[stockCode] = item.code it[stockName] = item.name it[quantity] = item.quantity it[profitRate] = item.profitRate it[stopLossRate] = item.stopLossRate it[targetPrice] = item.targetPrice it[stopLossPrice] = item.stopLossPrice it[orderNo] = item.orderNo it[status] = item.status it[isDomestic] = item.isDomestic } } /** * 상태 변경 및 가격 업데이트 (예: PENDING_BUY -> MONITORING) */ fun updateAutoTrade(item: AutoTradeItem) = transaction { val id = item.id ?: return@transaction AutoTradeTable.update({ AutoTradeTable.id eq id }) { it[targetPrice] = item.targetPrice it[stopLossPrice] = item.stopLossPrice it[orderNo] = item.orderNo it[status] = item.status } } /** * 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용) */ fun getActiveAutoTrades(): List = transaction { AutoTradeTable.select { AutoTradeTable.status inList listOf("MONITORING", "SELLING", "PENDING_BUY") }.map { mapToAutoTradeItem(it) } } /** * 종목코드로 현재 감시 중인 설정이 있는지 확인 (UI 체크박스 상태용) */ fun findConfigByCode(code: String): AutoTradeItem? = transaction { AutoTradeTable.select { (AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING") }.lastOrNull()?.let { mapToAutoTradeItem(it) } } fun deleteAutoTrade(id: Int) = transaction { AutoTradeTable.deleteWhere { AutoTradeTable.id eq id } } fun findAllPendingBuyCodes(): Set { return transaction { AutoTradeTable.select { (AutoTradeTable.status eq "PENDING_BUY") or (AutoTradeTable.status eq "ORDERED") }.map { it[AutoTradeTable.stockCode] }.toSet() } } fun findAllMonitoringTrades(): List { return transaction { AutoTradeTable.select { AutoTradeTable.status neq "COMPLETED" }.map { mapToAutoTradeItem(it) } } } private fun mapToAutoTradeItem(it: ResultRow) = AutoTradeItem( id = it[AutoTradeTable.id], code = it[AutoTradeTable.stockCode], name = it[AutoTradeTable.stockName], quantity = it[AutoTradeTable.quantity], profitRate = it[AutoTradeTable.profitRate], stopLossRate = it[AutoTradeTable.stopLossRate], targetPrice = it[AutoTradeTable.targetPrice], stopLossPrice = it[AutoTradeTable.stopLossPrice], orderNo = it[AutoTradeTable.orderNo], status = it[AutoTradeTable.status], isDomestic = it[AutoTradeTable.isDomestic], ) // --- 기존 설정 및 로그 관련 함수 --- fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) { transaction { TradeLogTable.insert { it[stockCode] = code it[stockName] = name it[tradeType] = type it[TradeLogTable.price] = price it[quantity] = qty it[timestamp] = LocalDateTime.now() it[logMessage] = msg } } } fun findConfigByAccount(accountNo: String): AppConfig? = transaction { ConfigTable.select { (ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo) }.lastOrNull()?.let { AppConfig( realAppKey = it[ConfigTable.realAppKey], realSecretKey = it[ConfigTable.realSecretKey], realAccountNo = it[ConfigTable.realAccountNo], vtsAppKey = it[ConfigTable.vtsAppKey], vtsSecretKey = it[ConfigTable.vtsSecretKey], vtsAccountNo = it[ConfigTable.vtsAccountNo], nAppKey = it[ConfigTable.nAppKey], nSecretKey = it[ConfigTable.nSecretKey], dAppKey = it[ConfigTable.dAppKey], htsId = it[ConfigTable.htsId], isSimulation = it[ConfigTable.isSimulation], // htsId 로드 modelPath = it[ConfigTable.modelPath], embedModelPath = it[ConfigTable.embedModelPath], FEES_AND_TAXRATE = it[ConfigTable.fees_and_taxrate], MINIMUM_NET_PROFIT = it[ConfigTable.minimum_net_profit], BUY_WEIGHT = it[ConfigTable.buy_weight], MAX_BUDGET = it[ConfigTable.max_budget], MAX_PRICE = it[ConfigTable.max_price], MIN_PRICE = it[ConfigTable.min_price], MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score], SELL_PROFIT = it[ConfigTable.sell_profit], GRADE_5_BUY = it[ConfigTable.grade_5_buy], GRADE_5_PROFIT = it[ConfigTable.grade_5_profit], GRADE_4_BUY = it[ConfigTable.grade_4_buy], GRADE_4_PROFIT = it[ConfigTable.grade_4_profit], GRADE_3_BUY = it[ConfigTable.grade_3_buy], GRADE_3_PROFIT = it[ConfigTable.grade_3_profit], GRADE_2_BUY = it[ConfigTable.grade_2_buy], GRADE_2_PROFIT = it[ConfigTable.grade_2_profit], GRADE_1_BUY = it[ConfigTable.grade_1_buy], GRADE_1_PROFIT = it[ConfigTable.grade_1_profit], GRADE_1_ALLOCATIONRATE = it[ConfigTable.grade_1_allocationrate], GRADE_2_ALLOCATIONRATE = it[ConfigTable.grade_2_allocationrate], GRADE_3_ALLOCATIONRATE = it[ConfigTable.grade_3_allocationrate], GRADE_4_ALLOCATIONRATE = it[ConfigTable.grade_4_allocationrate], GRADE_5_ALLOCATIONRATE = it[ConfigTable.grade_5_allocationrate], stop_Loss = it[ConfigTable.stop_Loss], take_profit = it[ConfigTable.take_profit], loss_min = it[ConfigTable.loss_minrate], loss_max = it[ConfigTable.loss_maxrate], loss_money = it[ConfigTable.loss_max_money], MAX_COUNT = it[ConfigTable.max_count], ) } } fun saveConfig(config: AppConfig) { transaction { ConfigTable.deleteAll() ConfigTable.insert { it[realAppKey] = config.realAppKey it[realSecretKey] = config.realSecretKey it[vtsAppKey] = config.vtsAppKey it[vtsSecretKey] = config.vtsSecretKey it[realAccountNo] = config.realAccountNo it[vtsAccountNo] = config.vtsAccountNo it[nAppKey] = config.nAppKey it[nSecretKey] = config.nSecretKey it[dAppKey] = config.dAppKey it[isSimulation] = config.isSimulation it[htsId] = config.htsId it[modelPath] = config.modelPath it[embedModelPath] = config.embedModelPath it[fees_and_taxrate] = config.FEES_AND_TAXRATE it[minimum_net_profit] = config.MINIMUM_NET_PROFIT it[buy_weight] = config.BUY_WEIGHT it[max_budget] = config.MAX_BUDGET it[max_price] = config.MAX_PRICE it[min_price] = config.MIN_PRICE it[min_purchase_score] = config.MIN_PURCHASE_SCORE it[sell_profit] = config.SELL_PROFIT it[grade_5_buy] = config.GRADE_5_BUY it[grade_5_profit] = config.GRADE_5_PROFIT it[grade_4_buy] = config.GRADE_4_BUY it[grade_4_profit] = config.GRADE_4_PROFIT it[grade_3_buy] = config.GRADE_3_BUY it[grade_3_profit] = config.GRADE_3_PROFIT it[grade_2_buy] = config.GRADE_2_BUY it[grade_2_profit] = config.GRADE_2_PROFIT it[grade_1_buy] = config.GRADE_1_BUY it[grade_1_profit] = config.GRADE_1_PROFIT it[grade_5_allocationrate] = config.GRADE_5_ALLOCATIONRATE it[grade_4_allocationrate] = config.GRADE_4_ALLOCATIONRATE it[grade_3_allocationrate] = config.GRADE_3_ALLOCATIONRATE it[grade_2_allocationrate] = config.GRADE_2_ALLOCATIONRATE it[grade_1_allocationrate] = config.GRADE_1_ALLOCATIONRATE it[stop_Loss] = config.stop_Loss it[take_profit] = config.take_profit it[loss_maxrate] = config.loss_max it[loss_minrate] = config.loss_min it[loss_max_money] = config.loss_money it[max_count] = config.MAX_COUNT } } } fun saveOrUpdate(item: AutoTradeItem) = transaction { val existing = AutoTradeTable.select { AutoTradeTable.orderNo eq item.orderNo }.firstOrNull() if (existing == null) { AutoTradeTable.insert { it[orderNo] = item.orderNo it[stockCode] = item.code it[stockName] = item.name it[status] = item.status it[targetPrice] = item.targetPrice it[stopLossPrice] = item.stopLossPrice it[quantity] = item.quantity it[isDomestic] = item.isDomestic } } else { AutoTradeTable.update({ AutoTradeTable.orderNo eq item.orderNo }) { it[status] = item.status it[targetPrice] = item.targetPrice it[stopLossPrice] = item.stopLossPrice } } } /** * 주문번호로 항목 조회 (가장 핵심적인 식별자) */ fun findByOrderNo(orderNo: String): AutoTradeItem? = transaction { AutoTradeTable.select { AutoTradeTable.orderNo eq orderNo } .map { mapToAutoTradeItem(it) } .singleOrNull() } /** * 서버 동기화: DB에는 PENDING_BUY/MONITORING인데 서버 미체결 내역에 없는 경우 EXPIRED로 변경 */ fun syncWithServer(serverOrderNos: List) = transaction { AutoTradeTable.update({ (AutoTradeTable.status inList listOf(TradeStatus.PENDING_BUY, TradeStatus.MONITORING)) and (AutoTradeTable.orderNo notInList serverOrderNos) }) { it[status] = TradeStatus.EXPIRED } } /** * 상태 업데이트 및 주문번호 갱신 (예: 매수체결 시 신규 익절 주문번호로 교체) */ fun updateStatusAndOrderNo(id: Int, newStatus: String, newOrderNo: String? = null) = transaction { AutoTradeTable.update({ AutoTradeTable.id eq id }) { it[status] = newStatus if (newOrderNo != null) it[orderNo] = newOrderNo } } /** * 감시 중인 모든 종목 리스트 (Status별 필터링 용이하게 수정) */ fun getAutoTradesByStatus(statusList: List): List = transaction { AutoTradeTable.select { AutoTradeTable.status inList statusList } .map { mapToAutoTradeItem(it) } } } @Serializable data class AutoTradeItem( val id: Int? = null, // DB 식별자 val orderNo: String, // 핵심 키: KIS 주문번호 (odno) val code: String, // 종목 코드 val name: String, // 종목 명 // 상태 머신 (PENDING_BUY, MONITORING, SELLING, EXPIRED, COMPLETED) var status: String = "PENDING_BUY", // 가격 정보 val orderedPrice: Double = 0.0, // 주문 단가 var targetPrice: Double = 0.0, // 익절 목표가 var stopLossPrice: Double = 0.0, // 손절 목표가 // 수량 정보 val quantity: Int = 0, // 총 주문 수량 var remainedQuantity: Int = 0, // 미체결 잔량 (서버 동기화용) val isDomestic: Boolean = true, val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율 val stopLossRate: Double = 0.0 ) object TradingLogStore { // UI에서 관찰할 수 있는 경량 로그 리스트 val decisionLogs = mutableStateListOf() data class LogEntry( val time: String, val stockName: String, val decision: String, val confidence: Double, val reason: String ) fun addLog(decision: TradingDecision) { synchronized(this) { if (decisionLogs.size > 100) decisionLogs.removeAt(0) decisionLogs.add(LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "${decision.stockName}[${decision.currentPrice}]", decision = decision.decision ?: "HOLD", confidence = decision.confidence, reason = decision.reason ?: "" )) } } fun addSellLog(stockName: String,sellPrice : String , decision: String, log: String) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) decisionLogs.add( LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "${stockName}[${sellPrice}][]", decision = decision, confidence = 100.0, reason = log ) ) } } fun addLog(tradingDecision: TradingDecision, decision: String, log: String) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) decisionLogs.add( LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "${tradingDecision.stockName}[${tradingDecision.currentPrice}][]", decision = decision, confidence = tradingDecision.confidence, reason = log ) ) } } fun addAnalyzer(name : String, code : String, log: String, positive : Boolean = false) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) decisionLogs.add( LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "$name[$code] 분석", decision = if(positive) "ANALYZER" else "PASS", confidence = 100.0, reason = log ) ) } } fun addNotice(name : String, code : String, log: String) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) decisionLogs.add( LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "$name[$code] 분석", decision = "NOTICE", confidence = 100.0, reason = log ) ) } } fun addAfterMarketLog(name : String, code : String, log: String) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) decisionLogs.add( LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "$name[$code] 분석", decision = "AFTER", confidence = 100.0, reason = log ) ) } } fun addSettingLog(settingDesc : String, old : String, new : String, log: String) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) decisionLogs.add( LogEntry( time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), stockName = "설정변경[${settingDesc}][$old]->[$new]", decision = "SETTING", confidence = 100.0, reason = log ) ) } } fun clear() { synchronized(this) { decisionLogs.clear() } } }