This commit is contained in:
2026-03-13 16:37:53 +09:00
parent 8013e80a34
commit fcd6b5e800
9 changed files with 727 additions and 206 deletions
+249 -31
View File
@@ -1,6 +1,8 @@
package service
import AutoTradeItem
import TradingDecision
import androidx.compose.runtime.remember
import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,6 +19,8 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import model.CandleData
import model.ConfigIndex
import model.ExecutionData
import model.InvestmentGrade
import model.KisSession
import model.RankingStock
import model.RankingType
@@ -46,9 +50,6 @@ object AutoTradingManager {
private val lastTickTime = AtomicLong(System.currentTimeMillis())
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 WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
@@ -58,10 +59,246 @@ object AutoTradingManager {
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>()
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 실행
*/
fun startAutoDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
fun startAutoDiscoveryLoop() {
if (isRunning()) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
@@ -72,27 +309,22 @@ object AutoTradingManager {
val now = System.currentTimeMillis()
if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) {
println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.")
restartLoop(tradeService, callback)
restartLoop()
}
}
}
// 2. 메인 루프 실행
runDiscoveryLoop(tradeService, callback)
runDiscoveryLoop(KisTradeService, globalCallback)
}
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
println("resumePendingSellOrders")
// val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
// println("pendingSells >>> ${pendingSells.size}")
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} ")
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
@@ -113,8 +345,7 @@ object AutoTradingManager {
println("❌ [재주문 실패] ${holding.name}: ${it.message}")
}
} else {
// 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리
// DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED)
}
delay(200) // API 호출 부하 방지
}
@@ -167,17 +398,11 @@ object AutoTradingManager {
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
// if (now.minute % 5 == 0) {
// SystemSleepPreventer.sleepDisplay()
// } else {
// SystemSleepPreventer.wakeDisplay()
// }
when {
//장중
now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> {
waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
// 토큰 중 하나라도 만료 5분 전이거나 비어있다면 다시 준비 상태로 전환
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
if (isSystemReadyToday) {
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
@@ -231,18 +456,11 @@ object AutoTradingManager {
println("미확인 데이터 ${remainingCandidates.size}")
}
// [프로세스 3] 종목별 순회 분석
var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
if (now.minute % 2 == 0) {
// SystemSleepPreventer.sleepDisplay()
} else {
// SystemSleepPreventer.wakeDisplay()
}
totalCount--
val stock = iterator.next()
try {
@@ -411,9 +629,9 @@ object AutoTradingManager {
).awaitAll().flatten()
}
private fun restartLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
private fun restartLoop() {
discoveryJob?.cancel()
startAutoDiscoveryLoop(tradeService, callback)
startAutoDiscoveryLoop()
}
private suspend fun waitForNextCycle(minutes: Double) {
@@ -460,10 +678,10 @@ object AutoTradingManager {
}
}
fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) {
fun checkAndRestart() {
if (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
startAutoDiscoveryLoop(tradeService, callback)
startAutoDiscoveryLoop()
} else {
}