This commit is contained in:
lunaticbum 2026-03-13 16:37:53 +09:00
parent 8013e80a34
commit fcd6b5e800
9 changed files with 727 additions and 206 deletions

View File

@ -46,9 +46,10 @@ import service.SystemSleepPreventer
import service.TradingDecisionCallback import service.TradingDecisionCallback
import ui.DashboardScreen import ui.DashboardScreen
import ui.SettingsScreen import ui.SettingsScreen
import ui.TradingDecisionLog
// 화면 상태 정의 // 화면 상태 정의
enum class AppScreen { Settings, Dashboard } enum class AppScreen { Settings, Dashboard, TradingDecision }
fun getLlamaBinPath(): String { fun getLlamaBinPath(): String {
@ -75,26 +76,10 @@ fun getLlamaBinPath(): String {
fun main() = application { fun main() = application {
SystemSleepPreventer.start() SystemSleepPreventer.start()
LaunchedEffect(Unit) {
// NewsService나 KisTradeService에서 사용하는 client를 전달
// DartCodeManager.updateCorpCodes(HttpClient(CIO) {
// install(ContentNegotiation) {
// json(Json {
// ignoreUnknownKeys = true
// encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
// })
// }
// // [수정] 모든 로그(Headers + Body)를 찍도록 설정
// install(Logging) {
// logger = Logger.DEFAULT
// level = LogLevel.BODY
// }
// })
}
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
val binPath = getLlamaBinPath() val binPath = getLlamaBinPath()
val windowState = rememberWindowState( val windowState = rememberWindowState(
placement = WindowPlacement.Maximized placement = WindowPlacement.Fullscreen
) )
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) { Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) } var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
@ -127,6 +112,7 @@ fun main() = application {
MAX_PRICE = it[ConfigTable.max_price], MAX_PRICE = it[ConfigTable.max_price],
MIN_PRICE = it[ConfigTable.min_price], MIN_PRICE = it[ConfigTable.min_price],
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score], MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
SELL_PROFIT = it[ConfigTable.sell_profit],
GRADE_5_BUY = it[grade_5_buy], GRADE_5_BUY = it[grade_5_buy],
GRADE_5_PROFIT = it[grade_5_profit], GRADE_5_PROFIT = it[grade_5_profit],
GRADE_4_BUY = it[grade_4_buy], GRADE_4_BUY = it[grade_4_buy],
@ -143,6 +129,7 @@ fun main() = application {
} }
isLoaded = true isLoaded = true
} }
if (!isLoaded) { if (!isLoaded) {
@ -158,7 +145,10 @@ fun main() = application {
val config = KisSession.config val config = KisSession.config
AutoTradingManager.isSystemReadyToday = true AutoTradingManager.isSystemReadyToday = true
AutoTradingManager.isSystemCleanedUpToday = false AutoTradingManager.isSystemCleanedUpToday = false
// LLM 서버 시작 (설정된 모델 경로 사용) CoroutineScope(Dispatchers.Default).launch {
AutoTradingManager.startAutoDiscoveryLoop()
}
if (config.modelPath.isNotEmpty()) { if (config.modelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.modelPath,port = 8080) LlamaServerManager.startServer(binPath, config.modelPath,port = 8080)
} }
@ -167,13 +157,16 @@ fun main() = application {
} }
// 대시보드로 화면 전환 // 대시보드로 화면 전환
currentScreen = AppScreen.Dashboard currentScreen = AppScreen.TradingDecision
} }
) )
} }
AppScreen.Dashboard -> { AppScreen.Dashboard -> {
DashboardScreen() DashboardScreen()
} }
AppScreen.TradingDecision -> {
TradingDecisionLog()
}
} }
} }
} }

View File

@ -1,4 +1,5 @@
import AutoTradeTable.orderNo import AutoTradeTable.orderNo
import androidx.compose.runtime.mutableStateListOf
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import model.AppConfig import model.AppConfig
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
@ -7,6 +8,8 @@ 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
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
object TradeStatus { object TradeStatus {
@ -42,6 +45,7 @@ object ConfigTable : Table("app_config") {
val max_price = double("max_price").default( 40000.0) val max_price = double("max_price").default( 40000.0)
val min_price = double("min_price").default( 800.0) val min_price = double("min_price").default( 800.0)
val min_purchase_score = double("min_purchase_score").default( 65.0) val min_purchase_score = double("min_purchase_score").default( 65.0)
val sell_profit = double("sell_profit").default( 1.3)
val grade_5_buy = integer("grade_5_buy").default(0) val grade_5_buy = integer("grade_5_buy").default(0)
val grade_5_profit = double("grade_5_profit").default(1.8) val grade_5_profit = double("grade_5_profit").default(1.8)
val grade_4_buy = integer("grade_4_buy").default(1) val grade_4_buy = integer("grade_4_buy").default(1)
@ -226,6 +230,7 @@ object DatabaseFactory {
MAX_PRICE = it[ConfigTable.max_price], MAX_PRICE = it[ConfigTable.max_price],
MIN_PRICE = it[ConfigTable.min_price], MIN_PRICE = it[ConfigTable.min_price],
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score], MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
SELL_PROFIT = it[ConfigTable.sell_profit],
GRADE_5_BUY = it[ConfigTable.grade_5_buy], GRADE_5_BUY = it[ConfigTable.grade_5_buy],
GRADE_5_PROFIT = it[ConfigTable.grade_5_profit], GRADE_5_PROFIT = it[ConfigTable.grade_5_profit],
GRADE_4_BUY = it[ConfigTable.grade_4_buy], GRADE_4_BUY = it[ConfigTable.grade_4_buy],
@ -251,9 +256,9 @@ object DatabaseFactory {
it[vtsSecretKey] = config.vtsSecretKey it[vtsSecretKey] = config.vtsSecretKey
it[realAccountNo] = config.realAccountNo it[realAccountNo] = config.realAccountNo
it[vtsAccountNo] = config.vtsAccountNo it[vtsAccountNo] = config.vtsAccountNo
it[ConfigTable.nAppKey] = config.nAppKey it[nAppKey] = config.nAppKey
it[ConfigTable.nSecretKey] = config.nSecretKey it[nSecretKey] = config.nSecretKey
it[ConfigTable.dAppKey] = config.dAppKey it[dAppKey] = config.dAppKey
it[isSimulation] = config.isSimulation it[isSimulation] = config.isSimulation
it[htsId] = config.htsId it[htsId] = config.htsId
it[modelPath] = config.modelPath it[modelPath] = config.modelPath
@ -265,6 +270,7 @@ object DatabaseFactory {
it[max_price] = config.MAX_PRICE it[max_price] = config.MAX_PRICE
it[min_price] = config.MIN_PRICE it[min_price] = config.MIN_PRICE
it[min_purchase_score] = config.MIN_PURCHASE_SCORE 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_buy] = config.GRADE_5_BUY
it[grade_5_profit] = config.GRADE_5_PROFIT it[grade_5_profit] = config.GRADE_5_PROFIT
it[grade_4_buy] = config.GRADE_4_BUY it[grade_4_buy] = config.GRADE_4_BUY
@ -275,7 +281,7 @@ object DatabaseFactory {
it[grade_2_profit] = config.GRADE_2_PROFIT it[grade_2_profit] = config.GRADE_2_PROFIT
it[grade_1_buy] = config.GRADE_1_BUY it[grade_1_buy] = config.GRADE_1_BUY
it[grade_1_profit] = config.GRADE_1_PROFIT it[grade_1_profit] = config.GRADE_1_PROFIT
it[ConfigTable.max_count] =config.MAX_COUNT it[max_count] = config.MAX_COUNT
} }
} }
} }
@ -365,4 +371,48 @@ data class AutoTradeItem(
val isDomestic: Boolean = true, val isDomestic: Boolean = true,
val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율 val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율
val stopLossRate: Double = 0.0 val stopLossRate: Double = 0.0
) )
object TradingLogStore {
// UI에서 관찰할 수 있는 경량 로그 리스트
val decisionLogs = mutableStateListOf<LogEntry>()
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 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
)
)
}
}
}

View File

@ -12,9 +12,10 @@ enum class ConfigIndex(val index : Int,val label : String) {
MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"), MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"),
MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"), MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"),
MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"), MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"),
MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "주문 최대 개수"), MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "단일 종목 최대 주문 개수"),
SELL_PROFIT(MAX_COUNT_INDEX.index + 1, "보유 주식 매도 기준 수익율"),
GRADE_5_BUY(MAX_COUNT_INDEX.index + 1, "강력 추천 투자 매수 기준"), GRADE_5_BUY(SELL_PROFIT.index + 1, "강력 추천 투자 매수 기준"),
GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"), GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"),
GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"), GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"),
GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"), GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"),
@ -69,6 +70,8 @@ data class AppConfig(
var MAX_PRICE: Double = 40000.0, var MAX_PRICE: Double = 40000.0,
var MIN_PRICE: Double = 800.0, var MIN_PRICE: Double = 800.0,
var MIN_PURCHASE_SCORE: Double = 65.0, var MIN_PURCHASE_SCORE: Double = 65.0,
var SELL_PROFIT: Double = 1.3,
var GRADE_5_BUY : Int = 0, var GRADE_5_BUY : Int = 0,
var GRADE_4_BUY : Int = 1, var GRADE_4_BUY : Int = 1,
var GRADE_3_BUY : Int = 2, var GRADE_3_BUY : Int = 2,
@ -97,6 +100,7 @@ data class AppConfig(
ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value} ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value}
ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value} ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value}
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value} ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value}
ConfigIndex.SELL_PROFIT -> {SELL_PROFIT = value}
ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value} ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value}
ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value} ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value}
ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value} ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value}
@ -133,6 +137,7 @@ data class AppConfig(
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> { ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {
MIN_PURCHASE_SCORE MIN_PURCHASE_SCORE
} }
ConfigIndex.SELL_PROFIT -> {SELL_PROFIT }
ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()} ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()}
ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()} ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()}
ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()} ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()}

View File

@ -214,4 +214,60 @@ data class ExecutionData(
val price: String, val price: String,
val qty: String, val qty: String,
val isFilled: Boolean val isFilled: Boolean
) )
enum class InvestmentGrade(
val displayName: String,
val description: String,
val shortWeight: Double = 0.0,
val midWeight: Double = 0.0,
val longWeight: Double = 0.0,
val profitGuide: ConfigIndex,
val buyGuide: ConfigIndex,
) {
LEVEL_5_STRONG_RECOMMEND(
displayName = "최상급 추천",
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
shortWeight = 1.0,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_5_PROFIT,
buyGuide = ConfigIndex.GRADE_5_BUY,
),
LEVEL_4_BALANCED_RECOMMEND(
displayName = "균형 추천",
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
shortWeight = 0.8,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_4_PROFIT,
buyGuide = ConfigIndex.GRADE_4_BUY,
),
LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천",
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
shortWeight = 0.6,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_3_PROFIT,
buyGuide = ConfigIndex.GRADE_3_BUY,
),
LEVEL_2_HIGH_RISK(
displayName = "고위험 추천",
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
shortWeight = 1.0,
midWeight = 0.4,
longWeight = 0.4,
profitGuide = ConfigIndex.GRADE_2_PROFIT,
buyGuide = ConfigIndex.GRADE_2_BUY,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2,
profitGuide = ConfigIndex.GRADE_1_PROFIT,
buyGuide = ConfigIndex.GRADE_1_BUY,
)
}

View File

@ -1,6 +1,8 @@
package service package service
import AutoTradeItem
import TradingDecision import TradingDecision
import androidx.compose.runtime.remember
import getLlamaBinPath import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -17,6 +19,8 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import model.CandleData import model.CandleData
import model.ConfigIndex import model.ConfigIndex
import model.ExecutionData
import model.InvestmentGrade
import model.KisSession import model.KisSession
import model.RankingStock import model.RankingStock
import model.RankingType import model.RankingType
@ -46,9 +50,6 @@ object AutoTradingManager {
private val lastTickTime = AtomicLong(System.currentTimeMillis()) private val lastTickTime = AtomicLong(System.currentTimeMillis())
private var watchdogJob: Job? = null private var watchdogJob: Job? = null
// 설정 상수
private const val MIN_RISE_RATE = 0.1
private const val MAX_RISE_RATE = 21.0
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분 private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
@ -58,10 +59,246 @@ object AutoTradingManager {
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항) // private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>() private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>() private val retryCountMap = mutableMapOf<String, Int>()
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) {
// 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
TradingLogStore.addLog(completeTradingDecision)
println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}")
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 : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
println("""
사명 : ${completeTradingDecision.corpName}
신뢰도 : ${completeTradingDecision.confidence + append}
단기성 : ${completeTradingDecision.shortPossible() + append}
수익성 : ${completeTradingDecision.profitPossible()+ append}
안전성 : ${completeTradingDecision.safePossible()+ append}
${investmentGrade.displayName} : ${investmentGrade.description}
총점 : ${totalScore}
""".trimIndent())
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val gradeRate = (1.0 - (investmentGrade.ordinal * 0.1))
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 전달)
AutoTradingManager.excuteTrade(
decision = completeTradingDecision,
orderQty = min(calculatedQty, maxQty).toString(),
profitRate1 = finalMargin,
investmentGrade = investmentGrade,
)
} 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))
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
} else {
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
}
}
when (completeTradingDecision?.decision) {
"BUY" -> {
append = buyWeight
TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}")
resultCheck(completeTradingDecision)
}
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
"HOLD" -> {
append = 0.0
TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}")
resultCheck(completeTradingDecision)
}
else -> {
append = 0.0
println("[$stockCode] ${completeTradingDecision?.decision} resultCheck: ${completeTradingDecision?.reason}")
}
}
}
}
}
val MIN_CONFIDENCE = 70.0 // 최소 신뢰도
var append = 0.0
fun getInvestmentGrade(
ts: TradingDecision,
totalScore: Double,
confidence: Double
): InvestmentGrade {
// 1. 기본 조건 충족 여부
if (totalScore < 68.0 || confidence < 70.0) {
return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음)
}
// 2. 단기/중기/장기 패턴 기준
val ultraShort = ts.ultraShortScore
val short = ts.shortTermScore
val mid = ts.midTermScore
val long = ts.longTermScore
val shortAvg = listOf(ultraShort, short).average() // 초단기+단기
val midLongAvg = listOf(mid, long).average() // 중기+장기
return when {
// LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음
shortAvg >= 85.0 && midLongAvg >= 80.0 ->
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
// LEVEL_4: 중기·장기 기본 준수, 단기까지 양호
midLongAvg >= 75.0 && shortAvg >= 70.0 ->
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 ->
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
// LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매
shortAvg >= 75.0 && midLongAvg < 65.0 ->
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
// LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함
shortAvg >= 70.0 && midLongAvg < 55.0 ->
InvestmentGrade.LEVEL_1_SPECULATIVE
// 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립)
else ->
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
}
}
fun excuteTrade(decision: TradingDecision,orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
scope.launch {
var basePrice = decision.currentPrice
val tickSize = MarketUtil.getTickSize(basePrice)
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
var stockCode = decision.stockCode
var stockName = decision.stockName
val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble())
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice")
TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo")
val pRate = 0.4
val sRate = -1.5
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues(
ConfigIndex.PROFIT_INDEX) + tax))
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
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)
TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
}
.onFailure {
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
}
}
}
val executionCache = mutableMapOf<String, ExecutionData>()
val processingIds = mutableSetOf<String>() // 주문번호 기준 잠금
suspend fun syncAndExecute(orderNo: String) {
if (processingIds.contains(orderNo)) return
processingIds.add(orderNo)
try {
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
val execData = executionCache[orderNo]
if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) {
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
KisTradeService.postOrder(
stockCode = dbItem.code,
qty = dbItem.quantity.toString(),
price = finalTargetPrice.toLong().toString(),
isBuy = false
).onSuccess { newSellOrderNo ->
// 익절가 업데이트 및 상태 변경
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
// (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
executionCache.remove(orderNo)
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
}
} else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
}
}
} finally {
processingIds.remove(orderNo)
}
}
/** /**
* 자동 발굴 루프 시작 Watchdog 실행 * 자동 발굴 루프 시작 Watchdog 실행
*/ */
fun startAutoDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { fun startAutoDiscoveryLoop() {
if (isRunning()) return if (isRunning()) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작 // 1. 기존 Watchdog이 있다면 제거 후 새로 시작
@ -72,27 +309,22 @@ object AutoTradingManager {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) { if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) {
println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.") println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.")
restartLoop(tradeService, callback) restartLoop()
} }
} }
} }
// 2. 메인 루프 실행 // 2. 메인 루프 실행
runDiscoveryLoop(tradeService, callback) runDiscoveryLoop(KisTradeService, globalCallback)
} }
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
println("resumePendingSellOrders") println("resumePendingSellOrders")
// val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
// println("pendingSells >>> ${pendingSells.size}")
balance.holdings.forEach { holding -> balance.holdings.forEach { holding ->
// 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치)
// val balance = tradeService.fetchIntegratedBalance().getOrNull()
// val holding = balance?.holdings?.find { it.code == item.code }
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > 0.8) { if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ") println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
@ -113,8 +345,7 @@ object AutoTradingManager {
println("❌ [재주문 실패] ${holding.name}: ${it.message}") println("❌ [재주문 실패] ${holding.name}: ${it.message}")
} }
} else { } else {
// 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리
// DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED)
} }
delay(200) // API 호출 부하 방지 delay(200) // API 호출 부하 방지
} }
@ -167,17 +398,11 @@ object AutoTradingManager {
now = LocalTime.now(ZoneId.of("Asia/Seoul")) now = LocalTime.now(ZoneId.of("Asia/Seoul"))
currentTimeMillis = System.currentTimeMillis() currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고 lastTickTime.set(System.currentTimeMillis()) // 생존 신고
// if (now.minute % 5 == 0) {
// SystemSleepPreventer.sleepDisplay()
// } else {
// SystemSleepPreventer.wakeDisplay()
// }
when { when {
//장중 //장중
now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> { now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> {
waitTime = 0.2 waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
// 토큰 중 하나라도 만료 5분 전이거나 비어있다면 다시 준비 상태로 전환
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
if (isSystemReadyToday) { if (isSystemReadyToday) {
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
@ -231,18 +456,11 @@ object AutoTradingManager {
println("미확인 데이터 ${remainingCandidates.size}") println("미확인 데이터 ${remainingCandidates.size}")
} }
// [프로세스 3] 종목별 순회 분석
var totalCount = remainingCandidates.size var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}") println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator() val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) { while (iterator.hasNext()) {
if (now.minute % 2 == 0) {
// SystemSleepPreventer.sleepDisplay()
} else {
// SystemSleepPreventer.wakeDisplay()
}
totalCount-- totalCount--
val stock = iterator.next() val stock = iterator.next()
try { try {
@ -411,9 +629,9 @@ object AutoTradingManager {
).awaitAll().flatten() ).awaitAll().flatten()
} }
private fun restartLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) { private fun restartLoop() {
discoveryJob?.cancel() discoveryJob?.cancel()
startAutoDiscoveryLoop(tradeService, callback) startAutoDiscoveryLoop()
} }
private suspend fun waitForNextCycle(minutes: Double) { private suspend fun waitForNextCycle(minutes: Double) {
@ -460,10 +678,10 @@ object AutoTradingManager {
} }
} }
fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) { fun checkAndRestart() {
if (!isRunning()) { if (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...") println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
startAutoDiscoveryLoop(tradeService, callback) startAutoDiscoveryLoop()
} else { } else {
} }

View File

@ -58,14 +58,6 @@ fun DashboardScreen() {
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) } var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) } var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
fun setupAutoTradingWatchdog(tradeService: KisTradeService, callback: TradingDecisionCallback) {
CoroutineScope(Dispatchers.Default).launch {
// while (true) {
// delay(60000) // 1분마다 체크
AutoTradingManager.checkAndRestart(tradeService, callback)
// }
}
}
var callback = object : TradingDecisionCallback { var callback = object : TradingDecisionCallback {
@ -91,11 +83,6 @@ fun DashboardScreen() {
} }
} }
LaunchedEffect(Unit) {
// 화면이 완전히 그려지고 안정화될 때까지 1초 대기
delay(1000)
AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback)
}
// 리소스 정리는 여전히 DisposableEffect에서 수행 // 리소스 정리는 여전히 DisposableEffect에서 수행
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -108,13 +95,13 @@ fun DashboardScreen() {
var refreshTrigger by remember { mutableStateOf(0) } var refreshTrigger by remember { mutableStateOf(0) }
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
val executionCache = remember { mutableMapOf<String, ExecutionData>() } val executionCache = remember { mutableMapOf<String, ExecutionData>() }
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
LaunchedEffect(refreshTrigger) { LaunchedEffect(refreshTrigger) {
// setupAutoTradingWatchdog(tradeService,callback) // setupAutoTradingWatchdog(tradeService,callback)
} }
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
suspend fun syncAndExecute(orderNo: String) { suspend fun syncAndExecute(orderNo: String) {
if (processingIds.contains(orderNo)) return if (processingIds.contains(orderNo)) return
processingIds.add(orderNo) processingIds.add(orderNo)

View File

@ -22,6 +22,7 @@ import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.ConfigIndex import model.ConfigIndex
import model.InvestmentGrade
import model.KisSession import model.KisSession
import model.RankingStock import model.RankingStock
import network.KisTradeService import network.KisTradeService
@ -30,61 +31,7 @@ import util.MarketUtil
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class InvestmentGrade(
val displayName: String,
val description: String,
val shortWeight: Double = 0.0,
val midWeight: Double = 0.0,
val longWeight: Double = 0.0,
val profitGuide: ConfigIndex,
val buyGuide: ConfigIndex,
) {
LEVEL_5_STRONG_RECOMMEND(
displayName = "최상급 추천",
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
shortWeight = 1.0,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_5_PROFIT,
buyGuide = ConfigIndex.GRADE_5_BUY,
),
LEVEL_4_BALANCED_RECOMMEND(
displayName = "균형 추천",
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
shortWeight = 0.8,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_4_PROFIT,
buyGuide = ConfigIndex.GRADE_4_BUY,
),
LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천",
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
shortWeight = 0.6,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_3_PROFIT,
buyGuide = ConfigIndex.GRADE_3_BUY,
),
LEVEL_2_HIGH_RISK(
displayName = "고위험 추천",
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
shortWeight = 1.0,
midWeight = 0.4,
longWeight = 0.4,
profitGuide = ConfigIndex.GRADE_2_PROFIT,
buyGuide = ConfigIndex.GRADE_2_BUY,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2,
profitGuide = ConfigIndex.GRADE_1_PROFIT,
buyGuide = ConfigIndex.GRADE_1_BUY,
)
}
/** /**
@ -316,7 +263,7 @@ fun IntegratedOrderSection(
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName)) AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가") println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
} else { } else {
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달") println("✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
} }
} }
when (completeTradingDecision?.decision) { when (completeTradingDecision?.decision) {

View File

@ -24,6 +24,7 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.AppConfig import model.AppConfig
@ -53,17 +54,22 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
} }
} }
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) { LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) {
item { item {
Text("거래 방식 선택", style = MaterialTheme.typography.h6) Row(
Row(verticalAlignment = Alignment.CenterVertically) { modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
){
Text("투자 방식", style = MaterialTheme.typography.subtitle1)
Spacer(Modifier.width(10.dp))
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) }) RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) })
Text("실전투자") Text("실전")
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(10.dp))
RadioButton(selected = config.isSimulation, onClick = { config = config.copy() }) RadioButton(selected = config.isSimulation, onClick = { config = config.copy() })
Text("모의투자") Text("모의")
} }
Divider(Modifier.padding(vertical = 12.dp))
Divider(Modifier.padding(vertical = 10.dp))
OutlinedTextField( OutlinedTextField(
value = config.htsId, value = config.htsId,
onValueChange = { config = config.copy(htsId = it,) }, onValueChange = { config = config.copy(htsId = it,) },
@ -73,94 +79,126 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
) )
// 실전 3종 입력 // 실전 3종 입력
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold) Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.realAccountNo, onValueChange = { Row(
config = config.copy(realAccountNo = it,) modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
if(it.length >= 8) checkAndLoadConfig(it, true) verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
}, 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.realAccountNo, onValueChange = {
config = config.copy(realAccountNo = it,)
if (it.length >= 8) checkAndLoadConfig(it, true)
}, label = { Text("실전 계좌번호") }, modifier = Modifier.weight(0.5f))
OutlinedTextField(
value = config.realAppKey,
onValueChange = { config = config.copy(realAppKey = it,) },
label = { Text("실전 App Key") },
modifier = Modifier.weight(0.5f)
)
}
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(16.dp))
// 모의 3종 입력 // 모의 3종 입력
Text("모의투자 정보", fontWeight = FontWeight.Bold) Text("모의투자 정보", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.vtsAccountNo, onValueChange = { Row(
config = config.copy(vtsAccountNo = it,) modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
if(it.length >= 8) checkAndLoadConfig(it, false) verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
}, 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.vtsAccountNo, onValueChange = {
config = config.copy(vtsAccountNo = it,)
if (it.length >= 8) checkAndLoadConfig(it, false)
}, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f))
OutlinedTextField(
value = config.vtsAppKey,
onValueChange = { config = config.copy(vtsAppKey = it,) },
label = { Text("모의 App Key") },
modifier = Modifier.weight(0.5f)
)
}
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) 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)) Spacer(Modifier.height(10.dp))
Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth())
// AI 모델 경로 및 드래그 앤 드롭 Spacer(Modifier.height(10.dp))
Text("AI 모델 설정", fontWeight = FontWeight.Bold) Text("AI 모델 설정", fontWeight = FontWeight.Bold)
Box( Row(
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
.onExternalDrag(onDrop = { state -> verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
val data = state.dragData ){
if (data is DragData.FilesList) { Box(
val path = data.readFiles().firstOrNull()?.removePrefix("file:") modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,) .onExternalDrag(onDrop = { state ->
} val data = state.dragData
}), if (data is DragData.FilesList) {
contentAlignment = Alignment.Center val path = data.readFiles().firstOrNull()?.removePrefix("file:")
) { if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,)
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp) }
}),
contentAlignment = Alignment.Center
) {
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
}
Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
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,)
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
}
} }
Box( Spacer(Modifier.height(10.dp))
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
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,)
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
}
Spacer(Modifier.height(24.dp))
Button( Button(
modifier = Modifier.fillMaxWidth().height(50.dp), modifier = Modifier.fillMaxWidth().height(50.dp),
onClick = { onClick = {
scope.launch { scope.launch {
// isLoading = true var retryCount = 0
// 1. KisSession.config 업데이트 및 DB 저장 val maxRetries = 3
KisSession.config = config val retryDelay = 90_000L // 1분 30초
DatabaseFactory.saveConfig(config) var isAuthCompleted = false
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
}
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.BODY
}
})
val authService = KisAuthService
val tradeService = KisTradeService
val authSuccess = authService.refreshAllTokens()
val wsKeySuccess = tradeService.refreshWebsocketKey()
if (authSuccess && wsKeySuccess) { while (retryCount <= maxRetries && !isAuthCompleted) {
statusMessage = "✅ 인증 성공! LLM 시작 중..." if (retryCount > 0) {
onAuthSuccess() statusMessage = "⏳ 인증 재시도 중... (${retryCount}/${maxRetries}) - 1분 30초 후 재시작"
} else { delay(retryDelay)
statusMessage = "❌ 인증 실패. 키 정보를 확인하세요." }
// 1. 설정값 저장
KisSession.config = config
DatabaseFactory.saveConfig(config)
// 2. 법인코드 업데이트
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
})
// 3. 토큰 및 웹소켓 키 갱신
val authSuccess = KisAuthService.refreshAllTokens()
val wsKeySuccess = KisTradeService.refreshWebsocketKey()
if (authSuccess && wsKeySuccess) {
statusMessage = "✅ 인증 성공! LLM 시작 중..."
isAuthCompleted = true
onAuthSuccess()
} else {
retryCount++
if (retryCount > maxRetries) {
statusMessage = "❌ 인증 실패. 3회 재시도 후 중단되었습니다. 키 정보를 확인하세요."
} else {
statusMessage = "⚠️ 인증 실패. 잠시 후 자동으로 다시 시도합니다. (시도 $retryCount)"
}
}
} }
// isLoading = false
} }
} }
) { Text("설정 저장 및 실행") } ) { Text("설정 저장 및 실행") }

View File

@ -0,0 +1,227 @@
package ui
import AutoTradeItem
import TradingDecision
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.lazy.items
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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.ConfigIndex
import model.KisSession
import service.TechnicalAnalyzer
@Composable
fun TradingDecisionLog() {
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) {
Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6)
Divider(Modifier.padding(vertical = 8.dp))
LazyColumn(reverseLayout = true) { // 최신 로그가 위로 오게 함
items(TradingLogStore.decisionLogs) { log ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
elevation = 2.dp
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text("${log.time} - ${log.stockName}", fontWeight = FontWeight.Bold)
Text(
text = log.decision,
color = if (log.decision == "BUY") Color.Red else Color.Gray,
fontWeight = FontWeight.ExtraBold
)
}
Text("신뢰도: ${log.confidence}%", fontSize = 11.sp)
Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray)
}
}
}
}
}
Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) {
LazyVerticalGrid(
columns = GridCells.Fixed(2), // 2열 병렬 배치
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().fillMaxHeight().background(Color.White)
) {
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰 거래 기본 설정",
style = MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(top = 10.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.SELL_PROFIT,
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.subtitle2,
modifier = Modifier.padding(top = 10.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.body1,
modifier = Modifier.padding(top = 10.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
//
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(ConfigIndex.PROFIT_INDEX)) + 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(ConfigIndex.PROFIT_INDEX)) + 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
)
}
}
}
}
}
}