diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 97a28bf..4cd9a93 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,3 +1,14 @@ +import ConfigTable.grade_1_buy +import ConfigTable.grade_1_profit +import ConfigTable.grade_2_buy +import ConfigTable.grade_2_profit +import ConfigTable.grade_3_buy +import ConfigTable.grade_3_profit +import ConfigTable.grade_4_buy +import ConfigTable.grade_4_profit +import ConfigTable.grade_5_buy +import ConfigTable.grade_5_profit +import ConfigTable.max_count import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -82,7 +93,25 @@ fun main() = application { isSimulation = it[ConfigTable.isSimulation], htsId = it[ConfigTable.htsId], modelPath = it[ConfigTable.modelPath], - embedModelPath = it[ConfigTable.embedModelPath] + 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], + GRADE_5_BUY = it[grade_5_buy], + GRADE_5_PROFIT = it[grade_5_profit], + GRADE_4_BUY = it[grade_4_buy], + GRADE_4_PROFIT = it[grade_4_profit], + GRADE_3_BUY = it[grade_3_buy], + GRADE_3_PROFIT = it[grade_3_profit], + GRADE_2_BUY = it[grade_2_buy], + GRADE_2_PROFIT = it[grade_2_profit], + GRADE_1_BUY = it[grade_1_buy], + GRADE_1_PROFIT = it[grade_1_profit], + MAX_COUNT = it[max_count], ) } } diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 8b81555..efcbb2b 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -30,6 +30,24 @@ object ConfigTable : Table("app_config") { 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.8) + 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 grade_5_buy = integer("grade_5_buy").default(0) + val grade_5_profit = double("grade_5_profit").default(1.8) + val grade_4_buy = integer("grade_4_buy").default(1) + val grade_4_profit = double("grade_4_profit").default(1.3) + val grade_3_buy = integer("grade_3_buy").default(1) + val grade_3_profit = double("grade_3_profit").default(0.9) + val grade_2_buy = integer("grade_2_buy").default(2) + val grade_2_profit = double("grade_2_profit").default(0.7) + val grade_1_buy = integer("grade_1_buy").default(3) + val grade_1_profit = double("grade_1_profit").default(0.5) + val max_count = integer("max_count").default(20) override val primaryKey = PrimaryKey(id) } @@ -159,7 +177,7 @@ object DatabaseFactory { stopLossPrice = it[AutoTradeTable.stopLossPrice], orderNo = it[AutoTradeTable.orderNo], status = it[AutoTradeTable.status], - isDomestic = it[AutoTradeTable.isDomestic] + isDomestic = it[AutoTradeTable.isDomestic], ) // --- 기존 설정 및 로그 관련 함수 --- @@ -189,10 +207,28 @@ object DatabaseFactory { vtsAppKey = it[ConfigTable.vtsAppKey], vtsSecretKey = it[ConfigTable.vtsSecretKey], vtsAccountNo = it[ConfigTable.vtsAccountNo], - isSimulation = it[ConfigTable.isSimulation], - htsId = it[ConfigTable.htsId], // htsId 로드 + 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], + 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], + MAX_COUNT = it[ConfigTable.max_count], ) } } @@ -211,6 +247,24 @@ object DatabaseFactory { 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[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[ConfigTable.max_count] =config.MAX_COUNT } } } diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index d60a86c..72da7bf 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -2,13 +2,36 @@ package model import java.time.LocalDateTime -const val feesAndTaxRate = 0.33 -const val minimumNetProfit = 0.8 -const val buyWeight = 2.0 -val MAX_BUDGET = 80000.0 -val MAX_PRICE = 40000 -val MIN_PRICE = 800 -val MIN_PURCHASE_SCORE = 65.0 + + +enum class ConfigIndex(val index : Int,val label : String) { + TAX_INDEX(0,"세금 및 수수료"), + PROFIT_INDEX(TAX_INDEX.index + 1 , "기준 수익률"), + BUY_WEIGHT_INDEX(PROFIT_INDEX.index + 1, "매수 가중치"), + MAX_BUDGET_INDEX(BUY_WEIGHT_INDEX.index + 1, "종목 당 최대 투자금"), + MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"), + MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"), + MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"), + MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "주문 최대 개수"), + + GRADE_5_BUY(MAX_COUNT_INDEX.index + 1, "강력 추천 투자 매수 기준"), + GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"), + GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"), + GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"), + GRADE_1_BUY(GRADE_2_BUY.index + 1, "공격적초단기 매수 기준"), + + GRADE_5_PROFIT(GRADE_1_BUY.index + 1, "강력 추천 투자 목표 수익 비율"), + GRADE_4_PROFIT(GRADE_5_PROFIT.index + 1, "안정적 투자 목표 수익 비율"), + GRADE_3_PROFIT(GRADE_4_PROFIT.index + 1, "보수적 투자 목표 수익 비율"), + GRADE_2_PROFIT(GRADE_3_PROFIT.index + 1, "하이리스크,리턴 목표 수익 비율"), + GRADE_1_PROFIT(GRADE_2_PROFIT.index + 1, "공격적초단기 목표 수익 비율"); + companion object { + fun get(index : Int) = ConfigIndex.entries[index] + } + +} + + data class AppConfig( // [DB 저장 데이터] // 실전 3종 @@ -33,12 +56,91 @@ data class AppConfig( var websocketToken: String = "", val isSimulation: Boolean = true, val modelPath: String = "", - val embedModelPath: String = "") { + val embedModelPath: String = "", + var FEES_AND_TAXRATE: Double = 0.33, + var MINIMUM_NET_PROFIT: Double = 0.8, + var BUY_WEIGHT: Double = 2.0, + var MAX_BUDGET: Double = 80000.0, + var MAX_PRICE: Double = 40000.0, + var MIN_PRICE: Double = 800.0, + var MIN_PURCHASE_SCORE: Double = 65.0, + var GRADE_5_BUY : Int = 0, + var GRADE_4_BUY : Int = 1, + var GRADE_3_BUY : Int = 2, + var GRADE_2_BUY : Int = 2, + var GRADE_1_BUY : Int = 3, + var GRADE_5_PROFIT : Double = 1.8, + var GRADE_4_PROFIT : Double = 1.3, + var GRADE_3_PROFIT : Double = 0.9, + var GRADE_2_PROFIT : Double = 0.7, + var GRADE_1_PROFIT : Double = 0.5, + var MAX_COUNT : Int = 20, +) { val accountNo : String get() { return if (isSimulation) vtsAccountNo else realAccountNo } + + + fun setValues(index :ConfigIndex , value : Double) { + when (index) { + ConfigIndex.TAX_INDEX -> {FEES_AND_TAXRATE = value} + ConfigIndex.PROFIT_INDEX -> {MINIMUM_NET_PROFIT = value} + ConfigIndex.MAX_PRICE_INDEX -> {MAX_PRICE = value} + ConfigIndex.MIN_PRICE_INDEX -> {MIN_PRICE = value} + ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value} + ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value} + ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value} + ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value} + ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value} + ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value} + ConfigIndex.GRADE_2_PROFIT -> {GRADE_2_PROFIT = value} + ConfigIndex.GRADE_1_PROFIT -> {GRADE_1_PROFIT = value} + ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY = value.toInt()} + ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY = value.toInt()} + ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY = value.toInt()} + ConfigIndex.GRADE_2_BUY -> {GRADE_2_BUY = value.toInt()} + ConfigIndex.GRADE_1_BUY -> {GRADE_1_BUY = value.toInt()} + ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT = value.toInt()} + } + } + fun getValues(index :ConfigIndex) : Double { + return when (index) { + ConfigIndex.TAX_INDEX -> { + FEES_AND_TAXRATE + } + ConfigIndex.PROFIT_INDEX -> { + MINIMUM_NET_PROFIT + } + ConfigIndex.MAX_PRICE_INDEX -> { + MAX_PRICE + } + ConfigIndex.MIN_PRICE_INDEX -> { + MIN_PRICE + } + ConfigIndex.BUY_WEIGHT_INDEX -> { + BUY_WEIGHT + } + ConfigIndex.MAX_BUDGET_INDEX -> { + MAX_BUDGET + } + ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> { + MIN_PURCHASE_SCORE + } + ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()} + ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()} + ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()} + ConfigIndex.GRADE_2_BUY -> {GRADE_2_BUY.toDouble()} + ConfigIndex.GRADE_1_BUY -> {GRADE_1_BUY.toDouble()} + ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT} + ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT} + ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT} + ConfigIndex.GRADE_2_PROFIT -> {GRADE_2_PROFIT} + ConfigIndex.GRADE_1_PROFIT -> {GRADE_1_PROFIT} + ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()} + } + } } diff --git a/src/main/kotlin/network/KisAuthService.kt b/src/main/kotlin/network/KisAuthService.kt index c2b016d..c0cfe60 100644 --- a/src/main/kotlin/network/KisAuthService.kt +++ b/src/main/kotlin/network/KisAuthService.kt @@ -43,7 +43,6 @@ class KisAuthService { */ suspend fun refreshAllTokens(): Boolean = coroutineScope { val config = KisSession.config - println("refreshAllTokens") // 1. 실전 시세용 토큰 발급 (Market Token) val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) } @@ -75,7 +74,6 @@ class KisAuthService { private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result { return try { - println("fetchAccessToken") val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") { contentType(ContentType.Application.Json) setBody(TokenRequest("client_credentials", appKey, secretKey)) diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index c7fa080..fb6a71b 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -21,6 +21,7 @@ import model.StockBalanceResponse import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -68,7 +69,11 @@ object KisTradeService { code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty, avgPrice = it.pchs_avg_pric, currentPrice = it.prpr, profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = true - )) + ).apply { + if (it.hldg_qty.toLong() > 0) { + println("보유 종목 : ${it.prdt_name} , 수량 : ${it.hldg_qty}") + } + }) } // 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑) @@ -437,7 +442,7 @@ object KisTradeService { if (response.status == HttpStatusCode.OK) { val approvalKey = response.body>()["approval_key"] if (approvalKey != null) { - KisSession.config = KisSession.config.copy(websocketToken = approvalKey) + KisSession.config = KisSession.config.copy(websocketToken = approvalKey,) true } else false } else false @@ -500,35 +505,65 @@ object KisTradeService { // --- 내부 Raw 호출용 (통합 잔고에서 사용) --- private suspend fun fetchDomesticRawBalance(): Result { - val config = KisSession.config - val baseUrl = prodUrl - val trId = "TTTC8434R" - var pureAccount = config.accountNo.replace("-", "").trim() - if (pureAccount.length == 8) pureAccount += "01" + val config = KisSession.config + val baseUrl = prodUrl + val trId = "TTTC8434R" - val cano = pureAccount.take(8) - val acntPrdtCd = pureAccount.takeLast(2) - return try { - val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { - 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) - parameter("CANO", cano) - parameter("ACNT_PRDT_CD", acntPrdtCd) - parameter("AFHR_FLPR_YN", "N") - parameter("OFL_YN", "N") - parameter("INQR_DVSN", "0") - parameter("UNPR_DVSN", "01") - parameter("FUND_STTL_ICLD_YN", "N") - parameter("FNCG_AMT_AUTO_RDPT_YN", "N") - parameter("PRCS_DVSN", "00") - parameter("CTX_AREA_FK100", "") - parameter("CTX_AREA_NK100", "") + val allHoldings = mutableListOf() + var totalBalance: StockBalanceResponse? = null + + // 연속 조회를 위한 변수 + var ctxAreaFk = "" + var ctxAreaNk = "" + var trCont = "N" // 'N': 최초 조회, 'F': 다음 조회, 'M': 연속 조회 + + try { + do { + val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { + 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("tr_cont", trCont) // 연속 조회 키 설정 + + val pureAccount = config.realAccountNo.replace("-", "").trim() + parameter("CANO", pureAccount.take(8)) + parameter("ACNT_PRDT_CD", pureAccount.takeLast(2)) + parameter("AFHR_FLPR_YN", "N") + parameter("OFL_YN", "N") + parameter("INQR_DVSN", "0") + parameter("UNPR_DVSN", "01") + parameter("FUND_STTL_ICLD_YN", "N") + parameter("FNCG_AMT_AUTO_RDPT_YN", "N") + parameter("PRCS_DVSN", "00") + // 연속 조회 파라미터 전달 + parameter("CTX_AREA_FK100", ctxAreaFk) + parameter("CTX_AREA_NK100", ctxAreaNk) + } + + val body = response.body() + + // 데이터 합치기 + allHoldings.addAll(body.output1) + if (totalBalance == null) totalBalance = body + + // 헤더에서 다음 조회를 위한 키값 추출 + trCont = response.headers["tr_cont"] ?: "D" // 'D' 또는 'E'는 끝을 의미 + ctxAreaFk = response.headers["ctx_area_fk100"] ?: "" + ctxAreaNk = response.headers["ctx_area_nk100"] ?: "" + delay(250) + } while (trCont == "F" || trCont == "M") // 연속 데이터가 있는 동안 반복 + // 모든 데이터를 합친 최종 객체 반환 + return if (totalBalance != null) { + Result.success(totalBalance.copy(output1 = allHoldings)) + } else { + println(totalBalance.toString()) + Result.failure(Exception("No data found")) + } + } catch (e: Exception) { + e.printStackTrace() + return Result.failure(e) } - val body = response.body() - Result.success(body) - } catch (e: Exception) { Result.failure(e) } } private suspend fun fetchOverseasRawBalance(): Result { diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index dc6a3c8..f32ac48 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -137,6 +137,7 @@ object RagService { try { var tradingDecision: TradingDecision = TradingDecision() tradingDecision.stockCode = stockCode + tradingDecision.analyzer = technicalAnalyzer tradingDecision.currentPrice = currentPrice var corpInfo = DartCodeManager.getCorpCode(stockCode) corpInfo?.stockName = stockName @@ -358,7 +359,7 @@ class TradingDecision { var techSummary : String? = null var newsContext : String? = null var financialData : String? = null - + var analyzer : TechnicalAnalyzer? = null fun shortPossible() = listOf(ultraShortScore, diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 81e9984..3f31c52 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -13,16 +13,17 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable import model.CandleData -import model.MAX_BUDGET -import model.MAX_PRICE -import model.MIN_PRICE +import model.ConfigIndex +import model.KisSession import model.RankingStock import model.RankingType import network.DartCodeManager import network.FinancialMapper import network.FinancialStatement import network.KisTradeService +import util.MarketUtil import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId @@ -76,6 +77,40 @@ object AutoTradingManager { runDiscoveryLoop(tradeService, callback) } + + suspend fun resumePendingSellOrders(tradeService: KisTradeService) { + // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. + val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED)) + + pendingSells.forEach { item -> + // 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치) + val balance = tradeService.fetchIntegratedBalance().getOrNull() + val holding = balance?.holdings?.find { it.code == item.code } + + if (holding != null && holding.quantity.toInt() > 0) { + var final = MarketUtil.roundToTickSize(item.targetPrice) + println("🔄 [재주문] ${item.name} (${item.code}) ${item.orderedPrice} ${final} 전날 미체결 매도 건 재주문 시도") + // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 + tradeService.postOrder( + stockCode = item.code, + qty = item.quantity.toString(), + price = final.toLong().toString(), + isBuy = false + ).onSuccess { newOrderNo -> + // 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지 + DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo) + println("✅ [재주문 완료] ${item.name}: $newOrderNo") + }.onFailure { + println("❌ [재주문 실패] ${item.name}: ${it.message}") + } + } else { + // 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리 + DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED) + } + delay(200) // API 호출 부하 방지 + } + } + private fun runDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { discoveryJob = scope.launch { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") @@ -91,7 +126,7 @@ object AutoTradingManager { val now = LocalTime.now(ZoneId.of("Asia/Seoul")) //&& now.isBefore(LocalTime.of(15, 30)) if (now.isAfter(LocalTime.of(15, 30)) ) { - executeClosingLiquidation(tradeService) +// executeClosingLiquidation(tradeService) return@withTimeout } @@ -136,7 +171,7 @@ object AutoTradingManager { iterator.remove() } println("남은 후보군 개수 : ${totalCount}") - delay(150) + delay(250) } println("⏱️ [Cycle End] ${LocalTime.now()}") @@ -145,10 +180,10 @@ object AutoTradingManager { println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") } catch (e: Exception) { println("⚠️ [Loop Error] ${e.message}") - delay(5000) + delay(3000) } - waitForNextCycle(0.5) + waitForNextCycle(0.3) } } } @@ -164,6 +199,9 @@ object AutoTradingManager { private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { try { + val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) + val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) + val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) // 개별 종목 분석은 최대 2분으로 제한 withTimeout(120000L) { val corpInfo = DartCodeManager.getCorpCode(stock.code) @@ -185,8 +223,8 @@ object AutoTradingManager { } val currentPrice = today.stck_prpr.toDouble() - if (currentPrice > myCash || currentPrice > MAX_BUDGET || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) { - print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${MIN_PRICE}, 최대 기준:${MAX_PRICE}] | ") + if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) { + print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ") return@withTimeout } @@ -251,7 +289,7 @@ object AutoTradingManager { while (System.currentTimeMillis() < endWait && isRunning()) { lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고 println("💤 대기 모드 상태 확인...") - delay(5000) + delay(1000) } } @@ -382,13 +420,41 @@ data class InvestmentScores( """.trimIndent() } } + +@Serializable class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() var daily: List = emptyList() var min30: List = emptyList() + fun isOverheatedStock(): Boolean { + if (min30.size < 20 || daily.size < 20) return false + val currentPrice = min30.last().stck_prpr.toDouble() + + // 1. 일봉 기준 이격도 체크 (20일 이평선 대비) + val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() + val disparityDaily = (currentPrice / ma20Daily) * 100 + // 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열) + if (disparityDaily > 125.0) return true + + // 2. 분봉(30분봉) 기준 단기 급등 체크 + val startPrice30 = min30.first().stck_oprc.toDouble() + val riseRate30 = ((currentPrice - startPrice30) / startPrice30) * 100 + // 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험 + if (riseRate30 > 15.0) return true + + // 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑) + val avgVol = min30.dropLast(3).map { it.cntg_vol.toDouble() }.average() + val recentVol = min30.last().cntg_vol.toDouble() + // 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음 + if (recentVol > avgVol * 10) return true + + // 4. 볼린저 밴드 상단 이탈 강도 + // ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인 + return false + } fun calculateScores( financialScore: Int // 재무제표 점수 (성장률 등 기반) diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index f3d475e..a06f1e4 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -51,8 +51,8 @@ fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockNa modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier - .fillMaxHeight() .fillMaxWidth() + .fillMaxHeight(0.15F) .verticalScroll(rememberScrollState()) // 스크롤 활성화 .padding(16.dp) .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) { diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index dd66c24..93c846f 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -5,22 +5,28 @@ import AutoTradeItem import TradingDecision import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import model.CandleData +import model.ConfigIndex import model.ExecutionData import model.KisSession import model.StockBasicInfo -import model.feesAndTaxRate -import model.minimumNetProfit import network.KisTradeService import network.KisWebSocketManager import service.AutoTradingManager @@ -54,7 +60,7 @@ fun DashboardScreen() { CoroutineScope(Dispatchers.Default).launch { // while (true) { // delay(60000) // 1분마다 체크 - AutoTradingManager.checkAndRestart(tradeService, callback) + AutoTradingManager.checkAndRestart(tradeService, callback) // } } } @@ -121,7 +127,7 @@ fun DashboardScreen() { // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) - val minEffectiveRate = minimumNetProfit + feesAndTaxRate + val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX) // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택 val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate) @@ -175,7 +181,7 @@ fun DashboardScreen() { val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY)) val monitoringCodes = monitoringTrades.map { it.code }.toSet() wsManager.updateSubscriptions(monitoringCodes) - + AutoTradingManager.resumePendingSellOrders(tradeService) refreshTrigger++ } @@ -191,7 +197,7 @@ fun DashboardScreen() { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { // [좌측 25%] 내 자산 및 통합 잔고 - Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { BalanceSection(tradeService, onRefresh = { refreshTrigger++ }, refreshTrigger = refreshTrigger) { code, name, isDom,qty -> @@ -235,7 +241,8 @@ fun DashboardScreen() { } VerticalDivider() - Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) { + + Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) { AiAnalysisView( technicalAnalyzer = TechnicalAnalyzer().apply { this.min30 = min30 @@ -254,9 +261,177 @@ fun DashboardScreen() { } } ) + Spacer(modifier = Modifier.height(16.dp)) + Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp)) + LazyVerticalGrid( + columns = GridCells.Fixed(2), // 2열 병렬 배치 + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().weight(0.3f) + ) { + item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + Text( + "💰 거래 기본 설정", + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + } + var defaults = arrayOf( + ConfigIndex.TAX_INDEX, + ConfigIndex.PROFIT_INDEX, + ConfigIndex.BUY_WEIGHT_INDEX, + ConfigIndex.MAX_BUDGET_INDEX, + ConfigIndex.MAX_PRICE_INDEX, + ConfigIndex.MIN_PRICE_INDEX, + ConfigIndex.MIN_PURCHASE_SCORE_INDEX, + ConfigIndex.MAX_COUNT_INDEX, + ) + items(defaults.size) { index -> + val configKey = defaults.get(index) + + // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) + var localText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } + + // 저장 로직을 공통 함수로 분리 + val saveAction = { + var newValue = localText.toDoubleOrNull() ?: 0.0 + if (configKey.label.contains("PROFIT")) { + newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + } + KisSession.config.setValues(configKey, newValue) + DatabaseFactory.saveConfig(KisSession.config) + println("💾 저장됨: ${configKey.label} = $newValue") + } + + var text = if (configKey.label.contains("PROFIT")) { + "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" + } else { + localText + } + + OutlinedTextField( + value = text, + onValueChange = { localText = it }, // 화면에는 즉시 반영 + label = { Text(configKey.label) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + // 2. 포커스를 잃었을 때 저장 + if (!focusState.isFocused) { + saveAction() + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Decimal + ), + keyboardActions = KeyboardActions( + // 3. 엔터(Done) 키를 눌렀을 때 저장 + onDone = { + saveAction() + } + ), + singleLine = true + ) + } + item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + Text( + "💰매수 정책 및 기대 수익률", + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + } + var defaults2 = arrayOf( + arrayOf(ConfigIndex.GRADE_5_BUY, + ConfigIndex.GRADE_5_PROFIT,), + arrayOf(ConfigIndex.GRADE_4_BUY, + ConfigIndex.GRADE_4_PROFIT,), + arrayOf(ConfigIndex.GRADE_3_BUY, + ConfigIndex.GRADE_3_PROFIT,), + arrayOf(ConfigIndex.GRADE_2_BUY, + ConfigIndex.GRADE_2_PROFIT,), + arrayOf(ConfigIndex.GRADE_1_BUY, + ConfigIndex.GRADE_1_PROFIT,), + ) + for (items in defaults2) { + val common = findLongestCommonSubstring(items.first().label,items.last().label) + item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함 + Text( + common, + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + } + + items(items.size) { index -> + val configKey = items.get(index) + + // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String) + var localText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } + + var labelText by remember(configKey) { + mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") + } + + val saveAction = { + var newValue = localText.toDoubleOrNull() ?: 0.0 +// if (configKey.name.contains("PROFIT")) { +// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) +// } + KisSession.config.setValues(configKey, newValue) + DatabaseFactory.saveConfig(KisSession.config) + println("💾 저장됨: ${configKey.label} = $newValue") + labelText = if (configKey.name.contains("PROFIT")) { + getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( + ConfigIndex.TAX_INDEX)}) = ${localText.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues( + ConfigIndex.TAX_INDEX)}" + } else { + getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" + } + } + + labelText = if (configKey.name.contains("PROFIT")) { + getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( + ConfigIndex.TAX_INDEX)}) = ${localText.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues( + ConfigIndex.TAX_INDEX)} " + } else { + getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" + } + + OutlinedTextField( + value = localText, + onValueChange = { localText = it }, // 화면에는 즉시 반영 + label = { Text(labelText) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + // 2. 포커스를 잃었을 때 저장 + if (!focusState.isFocused) { + saveAction() + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Decimal + ), + keyboardActions = KeyboardActions( + // 3. 엔터(Done) 키를 눌렀을 때 저장 + onDone = { + saveAction() + } + ), + singleLine = true + ) + } + } + } } VerticalDivider() - Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { AutoTradeSection( isDomestic = isDomestic, tradeService = tradeService, @@ -277,7 +452,7 @@ fun DashboardScreen() { } VerticalDivider() // [우측 30%] 시장 추천 TOP 20 (실전 데이터) - Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) { + Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) { MarketSection(tradeService) { code, name, isDom -> val info = StockBasicInfo( code = code, @@ -301,4 +476,34 @@ fun DashboardScreen() { @Composable fun VerticalDivider() { Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray)) +} + +fun findLongestCommonSubstring(s1: String, s2: String): String { + if (s1.isEmpty() || s2.isEmpty()) return "" + + var longest = "" + // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임 + val reference = if (s1.length <= s2.length) s1 else s2 + val target = if (s1.length <= s2.length) s2 else s1 + + for (i in reference.indices) { + for (j in (i + longest.length + 1)..reference.length) { + val sub = reference.substring(i, j) + if (target.contains(sub)) { + if (sub.length > longest.length) { + longest = sub + } + } else { + // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출 + break + } + } + } + return longest +} + +fun getRemaining(original: String, common: String): String { + if (common.isEmpty()) return original + // 가장 처음 발견되는 공통 문자열을 한 번만 제거 + return original.replaceFirst(common, "").trim() } \ No newline at end of file diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index ac83fe3..cd3a754 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -18,17 +18,16 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -import model.MAX_BUDGET -import model.MIN_PURCHASE_SCORE +import model.ConfigIndex +import model.KisSession import model.RankingStock -import model.buyWeight -import model.feesAndTaxRate -import model.minimumNetProfit import network.KisTradeService import service.AutoTradingManager import util.MarketUtil +import kotlin.math.min enum class InvestmentGrade( val displayName: String, @@ -36,7 +35,8 @@ enum class InvestmentGrade( val shortWeight: Double = 0.0, val midWeight: Double = 0.0, val longWeight: Double = 0.0, - val profitGuide: Double = 0.0, + val profitGuide: ConfigIndex, + val buyGuide: ConfigIndex, ) { LEVEL_5_STRONG_RECOMMEND( displayName = "최상급 추천", @@ -44,7 +44,8 @@ enum class InvestmentGrade( shortWeight = 1.0, midWeight = 1.0, longWeight = 1.0, - profitGuide = 1.8, + profitGuide = ConfigIndex.GRADE_5_PROFIT, + buyGuide = ConfigIndex.GRADE_5_BUY, ), LEVEL_4_BALANCED_RECOMMEND( displayName = "균형 추천", @@ -52,7 +53,8 @@ enum class InvestmentGrade( shortWeight = 0.8, midWeight = 1.0, longWeight = 1.0, - profitGuide = 1.3, + profitGuide = ConfigIndex.GRADE_4_PROFIT, + buyGuide = ConfigIndex.GRADE_4_BUY, ), LEVEL_3_CAUTIOUS_RECOMMEND( displayName = "보수적 추천", @@ -60,7 +62,8 @@ enum class InvestmentGrade( shortWeight = 0.6, midWeight = 1.0, longWeight = 1.0, - profitGuide = 0.9, + profitGuide = ConfigIndex.GRADE_3_PROFIT, + buyGuide = ConfigIndex.GRADE_3_BUY, ), LEVEL_2_HIGH_RISK( displayName = "고위험 추천", @@ -68,7 +71,8 @@ enum class InvestmentGrade( shortWeight = 1.0, midWeight = 0.4, longWeight = 0.4, - profitGuide = 0.7, + profitGuide = ConfigIndex.GRADE_2_PROFIT, + buyGuide = ConfigIndex.GRADE_2_BUY, ), LEVEL_1_SPECULATIVE( displayName = "순수 공격적 선택", @@ -76,7 +80,8 @@ enum class InvestmentGrade( shortWeight = 1.0, midWeight = 0.2, longWeight = 0.2, - profitGuide = 0.5, + profitGuide = ConfigIndex.GRADE_1_PROFIT, + buyGuide = ConfigIndex.GRADE_1_BUY, ) } @@ -128,7 +133,7 @@ fun IntegratedOrderSection( } var profitRate by remember(monitoringItem) { - mutableStateOf(monitoringItem?.profitRate?.toString() ?: minimumNetProfit.toString()) + mutableStateOf(monitoringItem?.profitRate?.toString() ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX).toString()) } var stopLossRate by remember(monitoringItem) { mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5") @@ -164,19 +169,19 @@ fun IntegratedOrderSection( return when { // LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음 shortAvg >= 85.0 && midLongAvg >= 80.0 -> - InvestmentGrade.LEVEL_5_STRONG_RECOMMEND + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // LEVEL_4: 중기·장기 기본 준수, 단기까지 양호 midLongAvg >= 75.0 && shortAvg >= 70.0 -> - InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형 midLongAvg >= 70.0 && shortAvg in 60.0..70.0 -> - InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매 shortAvg >= 75.0 && midLongAvg < 65.0 -> - InvestmentGrade.LEVEL_2_HIGH_RISK + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK // LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함 shortAvg >= 70.0 && midLongAvg < 55.0 -> @@ -184,30 +189,24 @@ fun IntegratedOrderSection( // 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립) else -> - InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND + if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK } } fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) { scope.launch { val tickSize = MarketUtil.getTickSize(basePrice) - val oneTickLowerPrice = basePrice - (tickSize * when(investmentGrade) { - InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> 0 - InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> 1 - InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> 2 - InvestmentGrade.LEVEL_2_HIGH_RISK -> 2 - InvestmentGrade.LEVEL_1_SPECULATIVE -> 3 - else -> 3 - }) + val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) - val finalPrice = if (orderPrice.isBlank()) { - oneTickLowerPrice.toLong().toString() + val finalPrice = MarketUtil.roundToTickSize(if (orderPrice.isBlank()) { + oneTickLowerPrice.toDouble() } else { - orderPrice - } + orderPrice.toDouble() + }) + println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") - tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) + tradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 onOrderResult("주문 성공: $realOrderNo", true) if (willEnableAutoSell) { @@ -220,7 +219,9 @@ fun IntegratedOrderSection( // 3. 실질 목표 수익률 계산 // 사용자가 입력한 pRate와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다. - val effectiveProfitRate = maxOf((profitRate1 ?: pRate) + feesAndTaxRate, minimumNetProfit + feesAndTaxRate) + var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues( + ConfigIndex.PROFIT_INDEX) + tax)) // 4. 보정된 수익률을 적용하여 목표가 계산 val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) @@ -241,7 +242,7 @@ fun IntegratedOrderSection( )) monitoringItem = DatabaseFactory.findConfigByCode(stockCode) onOrderSaved(realOrderNo) - onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.2f", effectiveProfitRate)}%): $realOrderNo", true) + onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo", true) } } .onFailure { onOrderResult(it.message ?: "매수 실패", false) } @@ -257,6 +258,11 @@ fun IntegratedOrderSection( completeTradingDecision.stockCode.equals(stockCode)) { basePrice = completeTradingDecision.currentPrice println("basePrice $basePrice") + val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) + val 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.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 @@ -269,10 +275,10 @@ fun IntegratedOrderSection( ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) + ((completeTradingDecision.safePossible() + append) * weights["safe"]!!) - if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) { + if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) { var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence) - val finalMargin = minimumNetProfit * investmentGrade.profitGuide + val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide) println(""" 사명 : ${completeTradingDecision.corpName} 신뢰도 : ${completeTradingDecision.confidence + append} @@ -287,19 +293,19 @@ fun IntegratedOrderSection( // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장) val calculatedQty = if (basePrice > 0) { - (MAX_BUDGET / basePrice).toInt().coerceAtLeast(1) + (maxBudget / basePrice).toInt().coerceAtLeast(1) } else { 1 } // 5. 매수 실행 (계산된 finalMargin 전달) excuteTrade( willEnableAutoSell = true, - orderQty = calculatedQty.toString(), + orderQty = min(calculatedQty, KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX).toInt()).toString(), profitRate1 = finalMargin, investmentGrade = investmentGrade, ) - } else if(totalScore >= (MIN_PURCHASE_SCORE * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) { + } else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) { AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가") } else { diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index aff5560..6a24d52 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -47,16 +47,16 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { item { Text("거래 방식 선택", style = MaterialTheme.typography.h6) Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) }) + RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) }) Text("실전투자") Spacer(Modifier.width(16.dp)) - RadioButton(selected = config.isSimulation, onClick = { config = config.copy(isSimulation = true) }) + RadioButton(selected = config.isSimulation, onClick = { config = config.copy() }) Text("모의투자") } Divider(Modifier.padding(vertical = 12.dp)) OutlinedTextField( value = config.htsId, - onValueChange = { config = config.copy(htsId = it) }, + onValueChange = { config = config.copy(htsId = it,) }, label = { Text("HTS ID (실시간 체결 통보용)") }, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") } @@ -64,22 +64,22 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { // 실전 3종 입력 Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold) OutlinedTextField(value = config.realAccountNo, onValueChange = { - config = config.copy(realAccountNo = it) + config = config.copy(realAccountNo = it,) if(it.length >= 8) checkAndLoadConfig(it, true) }, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) + OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it,) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) Spacer(Modifier.height(16.dp)) // 모의 3종 입력 Text("모의투자 정보", fontWeight = FontWeight.Bold) OutlinedTextField(value = config.vtsAccountNo, onValueChange = { - config = config.copy(vtsAccountNo = it) + config = config.copy(vtsAccountNo = it,) if(it.length >= 8) checkAndLoadConfig(it, false) }, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) + OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it,) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) Divider(Modifier.padding(vertical = 16.dp)) @@ -91,7 +91,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { val data = state.dragData if (data is DragData.FilesList) { val path = data.readFiles().firstOrNull()?.removePrefix("file:") - if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path) + if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,) } }), contentAlignment = Alignment.Center @@ -104,7 +104,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { val data = state.dragData if (data is DragData.FilesList) { val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:") - if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath) + if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,) } }), contentAlignment = Alignment.Center