diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 611add1..23d24ac 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -17,6 +17,7 @@ import ConfigTable.loss_max_money import ConfigTable.loss_maxrate import ConfigTable.loss_minrate import ConfigTable.max_count +import ConfigTable.max_holding_count import ConfigTable.stop_Loss import ConfigTable.take_profit import Defines.DETAILLOG @@ -215,6 +216,7 @@ fun main() = application { loss_min = it[loss_minrate], loss_money = it[loss_max_money], MAX_COUNT = it[max_count], + max_holding_count = it[max_holding_count], ) } } diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 70bf8bb..656d0f2 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -71,10 +71,11 @@ object ConfigTable : Table("app_config") { val loss_max_money = double("loss_max_money").default(10000.0) - - - val max_count = integer("max_count").default(20) + + val max_holding_count = double("max_holding_count").default(100.0) + + override val primaryKey = PrimaryKey(id) } @@ -289,6 +290,7 @@ object DatabaseFactory { loss_max = it[ConfigTable.loss_maxrate], loss_money = it[ConfigTable.loss_max_money], MAX_COUNT = it[ConfigTable.max_count], + max_holding_count = it[ConfigTable.max_holding_count], ) } } @@ -339,6 +341,7 @@ object DatabaseFactory { it[loss_minrate] = config.loss_min it[loss_max_money] = config.loss_money it[max_count] = config.MAX_COUNT + it[max_holding_count] = config.max_holding_count } } } diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index 1202b6f..0eef864 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -39,7 +39,7 @@ enum class ConfigIndex(val index : Int,val label : String) { LOSS_MINRATE(STOP_LOSS.index + 1, "손절 최소 기준") , LOSS_MAXRATE(LOSS_MINRATE.index + 1, "손절 최소 기준") , LOSS_MAX_MONEY(LOSS_MAXRATE.index + 1, "손절 최대 금액") , - + MAX_HOLDING_COUNT(LOSS_MAX_MONEY.index + 1, "최대 보유 가능 종목 수") ; companion object { @@ -110,6 +110,7 @@ data class AppConfig( var loss_min : Double = 3.5, var loss_max : Double = 10.0, var loss_money : Double = 10000.0, + var max_holding_count : Double = 100.0, ) { val accountNo : String @@ -152,6 +153,7 @@ data class AppConfig( ConfigIndex.LOSS_MAX_MONEY -> { loss_money = value} ConfigIndex.STOP_LOSS -> { stop_Loss = value > 0.1} ConfigIndex.TAKE_PROFIT -> { take_profit = value > 0.1 } + ConfigIndex.MAX_HOLDING_COUNT -> { max_holding_count = value } } } fun getValues(index :ConfigIndex) : Double { @@ -200,6 +202,7 @@ data class AppConfig( ConfigIndex.STOP_LOSS -> {if(!stop_Loss) 0.0 else 1.0} ConfigIndex.TAKE_PROFIT -> {if(!take_profit) 0.0 else 1.0} ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()} + ConfigIndex.MAX_HOLDING_COUNT -> { max_holding_count} } } } diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index 208cf6d..246157c 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -178,7 +178,11 @@ data class UnfilledOrder( val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑 val ord_dvsn_name: String, val rvse_cncl_dvsn_name: String -) +) { + fun isBuyOrder() : Boolean { + return true + } +} @Serializable data class UnfilledResponse( diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index ee99eae..6a97984 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -391,7 +391,7 @@ object KisTradeService { */ suspend fun fetchUnfilledOrders(): Result> { val config = KisSession.config - if (config.isSimulation) return Result.success(emptyList()) +// if (config.isSimulation) return Result.success(emptyList()) val baseUrl = if (config.isSimulation) vtsUrl else prodUrl val trId = "TTTC0084R" var pureAccount = config.accountNo.replace("-", "").trim() diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 6053100..8d9e26f 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -45,6 +45,7 @@ import java.time.LocalTime import java.time.ZoneId import java.util.concurrent.atomic.AtomicLong import kotlin.collections.List +import kotlin.collections.filter // service/AutoTradingManager.kt typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit @@ -98,94 +99,10 @@ object AutoTradingManager { } } - // val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> -// if (isSuccess && completeTradingDecision != null) { -// // 1. 로그 저장소에 기록 (UI에서 이걸 읽음) -// TradingLogStore.addLog(completeTradingDecision) -// -// println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}") -// if (completeTradingDecision.confidence < 10) { -// addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) -// TradingLogStore.addLog(completeTradingDecision,"RETRY","분석 신뢰도 오류 인지로 재분석 대기열에 추가") -// }else if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) { -// var basePrice = completeTradingDecision.currentPrice -// var stockCode = completeTradingDecision.stockCode -// println("basePrice $basePrice") -// val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) -// var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) -// val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX) -// val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) -// -// fun resultCheck(completeTradingDecision :TradingDecision) { -// val weights = mapOf( -// "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 -// "profit" to 0.4, -// "safe" to 0.4 // 중장기 점수 비중 강화 -// ) -// -// val totalScore = -// ((completeTradingDecision.shortPossible() + append) * weights["short"]!!) + -// ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) + -// ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) -// -// if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { -// var investmentGrade = completeTradingDecision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE -// -// val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) -// println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}") -// -// // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) -// val gradeRate = KisSession.config.getValues(investmentGrade.allocationRate) -// val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt() -// maxBudget = maxBudget * gradeRate -// val calculatedQty = if (basePrice > 0) { -// (maxBudget / basePrice).toInt().coerceAtLeast(1) -// } else { -// 1 -// } -// // 5. 매수 실행 (계산된 finalMargin 전달) -// excuteTrade( -// decision = completeTradingDecision, -// orderQty = min(calculatedQty, maxQty).toString(), -// profitRate1 = finalMargin, -// investmentGrade = investmentGrade, -// ) -// -// } else if(totalScore >= (minScore * 0.9) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.9)) { -// addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) -// TradingLogStore.addLog(completeTradingDecision,"RETRY","✋ [관망] 토탈 스코어[$totalScore] 또는 신뢰도[${completeTradingDecision.confidence}] 미달 이나 약간의 오차로 재분석 대기열에 추가") -// } else { -// TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달") -// } -// } -// if (completeTradingDecision?.decision?.contains("매수") == true) { -// completeTradingDecision.decision = "BUY" -// } -// when (completeTradingDecision?.decision) { -// "BUY","매수" -> { -// append = buyWeight -// TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 : ${completeTradingDecision?.reason}") -// resultCheck(completeTradingDecision) -// } -// "SELL" -> { -// TradingLogStore.addLog(completeTradingDecision,"SELL","[$stockCode] 매도 추천 : ${completeTradingDecision?.reason}") -// println("[$stockCode] 매도: ${completeTradingDecision?.reason}") -// } -// "HOLD" -> { -// append = 0.0 -// TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 : ${completeTradingDecision?.reason}") -// resultCheck(completeTradingDecision) -// } -// else -> { -// append = 0.0 -// println("[$stockCode] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason}") -// } -// } -// } -// } -// } val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> - if (isSuccess && completeTradingDecision != null) { + val seoulZone = ZoneId.of("Asia/Seoul") + val now = LocalTime.now(ZoneId.of("Asia/Seoul")) + if (now.isBefore(H15M30) && now.isAfter(H08M45) && isSuccess && completeTradingDecision != null) { val decision = completeTradingDecision // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 @@ -209,6 +126,8 @@ object AutoTradingManager { } else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석 addToReanalysis(RankingStock(decision.stockCode, decision.stockName)) } + } else { + } } @@ -290,51 +209,63 @@ object AutoTradingManager { var stockCode = decision.stockCode var stockName = decision.stockName val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) + val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt() - println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") - KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) - .onSuccess { realOrderNo -> - // 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가 - println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") - TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo") +// 💡 2. 매수 실행 전, 안전장치 통과 여부 확인 + if (!canAddNewPosition(maxStocks)) { + // 제한에 걸렸다면, 매수 로직을 건너뛰고 매도(보유 종목 관리) 로직으로만 넘어갑니다. + println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.") - // 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장) - val sRate = -1.5 - var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) - // 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기 - val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax + // UI나 로그에 상태를 띄워주면 좋습니다. + TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단") + AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) + TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") + } else { + println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") + KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) + .onSuccess { realOrderNo -> + // 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가 + println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") + TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo") - val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) - val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) - val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 + // 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장) + val sRate = -1.5 + var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + // 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기 + val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax - DatabaseFactory.saveAutoTrade(AutoTradeItem( - orderNo = realOrderNo, - code = stockCode, - name = stockName, - quantity = inputQty, - profitRate = effectiveProfitRate, - stopLossRate = sRate, - targetPrice = calculatedTarget, - stopLossPrice = calculatedStop, - status = "PENDING_BUY", - isDomestic = true - )) - syncAndExecute(realOrderNo) + val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) + val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) + val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 - // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 - TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") - } - .onFailure { - println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") + DatabaseFactory.saveAutoTrade(AutoTradeItem( + orderNo = realOrderNo, + code = stockCode, + name = stockName, + quantity = inputQty, + profitRate = effectiveProfitRate, + stopLossRate = sRate, + targetPrice = calculatedTarget, + stopLossPrice = calculatedStop, + status = "PENDING_BUY", + isDomestic = true + )) + syncAndExecute(realOrderNo) - if (it.message?.contains("주문가능금액을 초과") == true) { - AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) - TradingLogStore.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가") - } else { - TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패") + // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 + TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo") } - } + .onFailure { + println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") + + if (it.message?.contains("주문가능금액을 초과") == true) { + AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) + TradingLogStore.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가") + } else { + TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패") + } + } + } } } var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy -> @@ -384,6 +315,7 @@ object AutoTradingManager { } } else if (dbItem.status == TradeStatus.SELLING) { println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") + myOredsAndBalanceCodes.remove(dbItem.code) TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리") DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) executionCache.remove(orderNo) @@ -583,6 +515,15 @@ object AutoTradingManager { private var lastRetryTime = 0L val binPath = getLlamaBinPath() + fun canAddNewPosition( // 대표님의 시스템에 맞는 미체결 주문 객체 리스트 + maxAllowedStocks: Int + ): Boolean { + + + // 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환 + return myOredsAndBalanceCodes.size < maxAllowedStocks + } + suspend fun tryRefreshToken() { try { // 2분 간격 재시도 로직 (처음 실행 시에는 lastRetryTime이 0이므로 즉시 실행) @@ -621,6 +562,7 @@ object AutoTradingManager { var now = LocalTime.now(ZoneId.of("Asia/Seoul")) var currentTimeMillis = System.currentTimeMillis() var waitTime = 0.2 + val H15M30 = LocalTime.of(15, 30) val H16 = LocalTime.of(16, 0) val H18 = LocalTime.of(18, 0) val H08M00 = LocalTime.of(8, 0) @@ -717,25 +659,28 @@ object AutoTradingManager { return batch } - - suspend fun checkBalance(isMorning: Boolean = true) : UnifiedBalance? { - var balance : UnifiedBalance? = null + var currentBalance : UnifiedBalance? = null + var myOredsAndBalanceCodes : MutableSet = mutableSetOf() + suspend fun checkBalance(isMorning: Boolean = true) { if (isMorning) { - balance = KisTradeService.fetchIntegratedBalance().getOrNull() - if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } - return balance + currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull() + if (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } } else { } - return null } suspend fun executeMarketLoop() { - val balance = checkBalance() -// if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) } + myOredsAndBalanceCodes.clear() + checkBalance() + val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L + val myHoldings = currentBalance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { + myOredsAndBalanceCodes.add(it.code) + it.code }?.toSet() ?: emptySet() + val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { + myOredsAndBalanceCodes.add(it.code) + it.code + } - val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L - val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() - val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } if (remainingCandidates.isEmpty()) { if (loadedTops.size < 100) { loadedTops.addAll(StockUniverseLoader.loadUniverse()) diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt index 428d317..bee246d 100644 --- a/src/main/kotlin/ui/TradingDecisionLog.kt +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -206,6 +206,7 @@ fun TradingDecisionLog() { ConfigIndex.MIN_PURCHASE_SCORE_INDEX, ConfigIndex.SELL_PROFIT, ConfigIndex.MAX_COUNT_INDEX, + ConfigIndex.MAX_HOLDING_COUNT, ) items(defaults.size) { index -> val configKey = defaults.get(index)