atrade/src/main/kotlin/service/AutoTradingManager.kt
2026-04-07 18:07:18 +09:00

1061 lines
52 KiB
Kotlin

package service
import AutoTradeItem
import Defines.AUTOSELL
import Defines.BLACKLISTEDSTOCKCODES
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
import network.TradingDecision
import TradingLogStore
import analyzer.TechnicalAnalyzer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
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.ConfigIndex
import model.ExecutionData
import model.KisSession
import model.RankingStock
import model.RankingType
import model.UnifiedBalance
import network.DartCodeManager
import network.KisAuthService
import network.KisTradeService
import network.KisWebSocketManager
import network.RagService
import network.StockUniverseLoader
import org.jetbrains.skia.ImageFilter
import util.MarketUtil
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.atomic.AtomicLong
import kotlin.collections.List
import kotlin.math.*
// service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
object AutoTradingManager {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var discoveryJob: Job? = null
// 모니터링을 위한 상태 변수
private val lastTickTime = AtomicLong(System.currentTimeMillis())
private var watchdogJob: Job? = null
private const val CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
private const val ONE_STOCK_ALYSIS_TIME = 180000L
fun isRunning(): Boolean = discoveryJob?.isActive == true
private var remainingCandidates = mutableListOf<RankingStock>()
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>()
var shouldShowFullWindow by mutableStateOf(false)
var llmAnalyser by mutableStateOf(false)
var llmNews by mutableStateOf(false)
var tradeToken by mutableStateOf(false)
var webSocketConnect by mutableStateOf(false)
var testFlag = false
fun startBackgroundScheduler() {
scope.launch {
while (isActive) {
val seoulZone = ZoneId.of("Asia/Seoul")
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
val nowDate = LocalDate.now(seoulZone)
var checkTime = 60_000 * 3L
val isTradingDay = nowDate.dayOfWeek.value in 1..5
if (isTradingDay && now.isAfter(H08M30) && now.isBefore(H18) && !shouldShowFullWindow) {
shouldShowFullWindow = true
SystemSleepPreventer.wakeDisplay()
} else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) {
SystemSleepPreventer.sleepDisplay()
}
if (!isTradingDay) {
checkTime = 60_000 * 30L
}
delay(checkTime) // 1분마다 체크
}
}
}
// val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
// if (isSuccess && completeTradingDecision != null) {
// // 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
// TradingLogStore.addLog(completeTradingDecision)
//
// println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}")
// if (completeTradingDecision.confidence < 10) {
// addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
// TradingLogStore.addLog(completeTradingDecision,"RETRY","분석 신뢰도 오류 인지로 재분석 대기열에 추가")
// }else if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) {
// var basePrice = completeTradingDecision.currentPrice
// var stockCode = completeTradingDecision.stockCode
// println("basePrice $basePrice")
// val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
// var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
// val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX)
// val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
//
// fun resultCheck(completeTradingDecision :TradingDecision) {
// val weights = mapOf(
// "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
// "profit" to 0.4,
// "safe" to 0.4 // 중장기 점수 비중 강화
// )
//
// val totalScore =
// ((completeTradingDecision.shortPossible() + append) * weights["short"]!!) +
// ((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) +
// ((completeTradingDecision.safePossible() + append) * weights["safe"]!!)
//
// if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
// var investmentGrade = completeTradingDecision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
//
// val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
// println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
//
// // basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
// val gradeRate = KisSession.config.getValues(investmentGrade.allocationRate)
// val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt()
// maxBudget = maxBudget * gradeRate
// val calculatedQty = if (basePrice > 0) {
// (maxBudget / basePrice).toInt().coerceAtLeast(1)
// } else {
// 1
// }
// // 5. 매수 실행 (계산된 finalMargin 전달)
// excuteTrade(
// decision = completeTradingDecision,
// orderQty = min(calculatedQty, maxQty).toString(),
// profitRate1 = finalMargin,
// investmentGrade = investmentGrade,
// )
//
// } else if(totalScore >= (minScore * 0.9) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.9)) {
// addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
// TradingLogStore.addLog(completeTradingDecision,"RETRY","✋ [관망] 토탈 스코어[$totalScore] 또는 신뢰도[${completeTradingDecision.confidence}] 미달 이나 약간의 오차로 재분석 대기열에 추가")
// } else {
// TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
// }
// }
// if (completeTradingDecision?.decision?.contains("매수") == true) {
// completeTradingDecision.decision = "BUY"
// }
// when (completeTradingDecision?.decision) {
// "BUY","매수" -> {
// append = buyWeight
// TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 : ${completeTradingDecision?.reason}")
// resultCheck(completeTradingDecision)
// }
// "SELL" -> {
// TradingLogStore.addLog(completeTradingDecision,"SELL","[$stockCode] 매도 추천 : ${completeTradingDecision?.reason}")
// println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
// }
// "HOLD" -> {
// append = 0.0
// TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 : ${completeTradingDecision?.reason}")
// resultCheck(completeTradingDecision)
// }
// else -> {
// append = 0.0
// println("[$stockCode] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason}")
// }
// }
// }
// }
// }
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) {
val decision = completeTradingDecision
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
if (decision.decision == "BUY") {
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
// 2. 최종 매수 실행
val gradeRate = KisSession.config.getValues(grade.allocationRate)
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
excuteTrade(
decision = decision,
orderQty = calculatedQty.toString(),
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
investmentGrade = grade
)
} else if (decision.confidence >= 60.0) { // 아까운 종목만 재분석
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
}
}
}
val MIN_CONFIDENCE = 60.0 // 최소 신뢰도
var append = 0.0
fun getInvestmentGrade(
ts: TradingDecision,
totalScore: Double,
confidence: Double
): InvestmentGrade {
// [개선] 하드코딩된 60/70 대신 사용자 설정 최소 점수를 기준으로 사용
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
val minConfidence = minScore // 신뢰도 하한선도 매수 기준 점수와 동기화
// 1. 최소 기준 미달 시 (관망 대상)
if (totalScore < (minScore * 0.8) || confidence < minConfidence) {
return InvestmentGrade.LEVEL_1_SPECULATIVE
}
// 2. 패턴 점수 추출
val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0
val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0
val isOverheated = ts.analyzer?.isOverheatedStock() ?: true
// 3. [개선] 점수 구간을 5~10점씩 하향 조정하여 실제 '추천' 등급이 나오도록 보정
val rawGrade = when {
// [A그룹] 중장기 추세가 강한 상태
midLongAvg >= 70.0 -> { // 75 -> 70 하향
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // 80 -> 75
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 70 -> 65
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
}
// [B그룹] 중장기 추세가 보통인 상태
midLongAvg >= 60.0 -> { // 65 -> 60 하향
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 75 -> 70
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // 65 -> 60
else InvestmentGrade.LEVEL_2_HIGH_RISK
}
// [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태
else -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
else InvestmentGrade.LEVEL_1_SPECULATIVE
}
}
// 4. 단기 과열 패널티 (일괄 1단계 강등)
return if (isOverheated) {
when (rawGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_2_HIGH_RISK
else -> InvestmentGrade.LEVEL_1_SPECULATIVE
}
} else {
rawGrade
}
}
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")
if (it.message?.contains("주문가능금액을 초과") == true) {
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
TradingLogStore.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가")
} else {
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
}
}
}
}
var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy ->
scope.launch {
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
executionCache[orderNo] = exec
syncAndExecute(orderNo)
}
}
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)
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
executionCache.remove(orderNo)
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}")
}
} else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
}
}
} finally {
processingIds.remove(orderNo)
}
}
/**
* 자동 발굴 루프 시작 및 Watchdog 실행
*/
fun startAutoDiscoveryLoop() {
if (isRunning()) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
watchdogJob?.cancel()
watchdogJob = scope.launch {
while (isActive) {
delay(WATCHDOG_CHECK_INTERVAL)
val now = System.currentTimeMillis()
if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) {
println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.")
restartLoop()
}
}
}
// 2. 메인 루프 실행
runDiscoveryLoop(globalCallback)
}
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
balance.holdings.forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
} else {
println("sellingAfterMarketOnePrice")
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
var targetPrice = holding.currentPrice.toDouble()
TradingLogStore.addAfterMarketLog(
holding.name,
holding.code,
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
)
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
orderDivision = if (marketCode.equals("Y")) "07" else "",
marketCode = if (marketCode.equals("Y")) "KRX" else "NXT"
).onSuccess { newOrderNo ->
println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 시간외 단일가 주식 재고털이 주문 완료"
)
}.onFailure {
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
)
}
}
delay(300) // API 호출 부하 방지
}
}
}
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
// if (isRunning()) return
val now = LocalTime.now()
val currentMinute = now.minute
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
println("resumePendingSellOrders")
balance.holdings.forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
} else {
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)로 다시 매도 주문 전송
var targetPrice = holding.currentPrice.toDouble()
val now = LocalTime.now()
val currentMinute = now.minute
var isBefore930 = false
if (now.hour == 9 && currentMinute < 30) {
targetPrice = targetPrice
isBefore930 = true
} else {
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
}
println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
).onSuccess { newOrderNo ->
println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
)
}.onFailure {
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 보유 주식 매도 주문 실패[${it.message}] "
)
}
} else {
TradingLogStore.addAnalyzer(
"보유주식[${holding.name}]",
holding.code,
"수익률 미달 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
}
delay(200) // API 호출 부하 방지
}
}
// }
}
var isSystemReadyToday = false
var isSystemCleanedUpToday = false
private var lastRetryTime = 0L
val binPath = getLlamaBinPath()
suspend fun tryRefreshToken() {
try {
// 2분 간격 재시도 로직 (처음 실행 시에는 lastRetryTime이 0이므로 즉시 실행)
if (currentTimeMillis - lastRetryTime >= 2 * 60 * 1000L) {
lastRetryTime = currentTimeMillis
println("🌅 [System] 오전 8시 업무 시작 준비 시도...")
SystemSleepPreventer.wakeDisplay() // 모니터 깨우기
val authSuccess = KisAuthService.refreshAllTokens()
val wsSuccess = KisTradeService.refreshWebsocketKey()
if (authSuccess && wsSuccess) {
println("✅ [System] 토큰 갱신 성공. AI 서버를 기동합니다.")
// 서버 시작 로직 실행 (Main.kt에 있던 로직 활용)
val config = KisSession.config
// LLM 서버 시작 (설정된 모델 경로 사용)
if (config.modelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.modelPath,port = LLM_PORT)
}
if (config.embedModelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT)
}
KisWebSocketManager.connect()
isSystemReadyToday = true
shouldShowFullWindow = true
} else {
println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.")
}
}
} catch (e: Exception) {}
}
var onMarketClosed: (() -> Unit)? = null
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
var currentTimeMillis = System.currentTimeMillis()
var waitTime = 0.2
val H16 = LocalTime.of(16, 0)
val H18 = LocalTime.of(18, 0)
val H08M35 = LocalTime.of(8, 35)
val H08M30 = LocalTime.of(8, 30)
private fun runDiscoveryLoop(callback: TradingDecisionCallback) {
discoveryJob = scope.launch {
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
while (isActive) {
try {
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
when {
now.isAfter(H18) || now.isBefore(H08M35) -> {
prepareMarketOpen(now)
}
now.isBefore(H18) && now.isAfter(H08M35) -> {
waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
if (isSystemReadyToday) {
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
isSystemReadyToday = false
KisWebSocketManager.disconnect()
tryRefreshToken()
}
}
}
withTimeout(CYCLE_TIMEOUT) {
println("⏱️ [Cycle Start] ${LocalTime.now()}")
if (now.isAfter(H18)) {
executeClosingLiquidation(KisTradeService)
} else {
executeMarketLoop()
}
}
}
//
// //장외
// now.isAfter(H16) || now.isBefore(H08M35) -> {
// finalizeMarketClose(now)
// }
else ->{
waitTime = 3.0
}
}
} catch (e: TimeoutCancellationException) {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
} catch (e: Exception) {
println("⚠️ [Loop Error] ${e.message}")
delay(1500)
}
waitForNextCycle(waitTime)
}
}
}
suspend fun prepareMarketOpen(now : LocalTime) {
if (now.isAfter(H18) || now.isBefore(H08M30)) {
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
onMarketClosed?.invoke()
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0)
LlamaServerManager.stopAll() // AI 서버 완전 종료
TradingLogStore.clear()
isSystemReadyToday = false
shouldShowFullWindow = false
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if (now.isAfter(H08M30) && now.isBefore(H08M35) && !isSystemReadyToday) {
if (MarketUtil.canTradeToday()) {
SystemSleepPreventer.wakeDisplay()
shouldShowFullWindow = true
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true)
} else {
println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.")
delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여
}
}
}
var loadedTops = mutableListOf<Pair<String, String>>()
fun poll100Stocks(): List<Pair<String, String>> {
val count = minOf(loadedTops.size, 150)
if (count == 0) return emptyList()
// 앞의 100개를 복사
val batch = loadedTops.subList(0, count).toList()
// 원본에서 삭제 (이 작업이 큐의 pop/remove 역할을 합니다)
loadedTops.subList(0, count).clear()
return batch
}
suspend fun checkBalance(isMorning: Boolean = true) : UnifiedBalance? {
var balance : UnifiedBalance? = null
if (isMorning) {
balance = KisTradeService.fetchIntegratedBalance().getOrNull()
if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
return balance
} else {
}
return null
}
suspend fun executeMarketLoop() {
val balance = checkBalance()
// if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
if (remainingCandidates.isEmpty()) {
if (loadedTops.size < 100) {
loadedTops.addAll(StockUniverseLoader.loadUniverse())
loadedTops.shuffle()
println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.")
}
poll100Stocks().forEach { (code, name) ->
addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name))
}
val candidates: MutableList<RankingStock> = fetchCandidates(KisTradeService).apply {
}.filter {
val rate = it.prdy_ctrt.toDouble()
val corpInfo = DartCodeManager.getCorpCode(it.code)
val isOk = (rate > 0 && rate < 15) || (rate < 0 && rate > -15)
if (corpInfo?.cName.isNullOrEmpty()) {
false
} else {
isOk
}
}
.filter { !it.name.contains("호스팩", true) }
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
.toMutableList()
if (reanalysisList.isNotEmpty()) {
candidates.addAll(reanalysisList)
}
reanalysisList.clear()
remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList}
.distinctBy { it.code })
} else {
println("미확인 데이터 ${remainingCandidates.size}")
}
var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
totalCount--
val stock = iterator.next()
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
if (BLACKLISTEDSTOCKCODES.contains(stock.code)) {
println("❌ 차단 처리된 주식 : ${stock.name}")
} else {
try {
processSingleStock(stock, myCash, KisTradeService, globalCallback)
} catch (e: Exception) {
println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}")
} finally {
iterator.remove()
}
println("남은 후보군 개수 : ${totalCount}")
delay(100)
}
// }
sellSchedule()
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
}
private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장
suspend fun sellSchedule() {
val now = LocalTime.now()
val currentMinute = now.minute
println("매도 스케줄 체크")
if (now.hour == 9 && (currentMinute % 10 == 1 || currentMinute % 10 == 7)) {
if (lastForceCheckMinute != currentMinute) {
TradingLogStore.addAnalyzer(" - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true)
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
checkBalance()
lastForceCheckMinute = currentMinute // 실행 완료 기록
}
}
else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3)) {
if (lastForceCheckMinute != currentMinute) {
TradingLogStore.addAnalyzer(
" - ",
" - ",
"⏰ [강제 스케줄 실행] 오후 ${now.hour}${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.",
true
)
listOf<String>("Y","X").forEach { code ->
KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
sellingAfterMarketOnePrice(KisTradeService, it, code)
}
}
lastForceCheckMinute = currentMinute // 실행 완료 기록
}
}
}
suspend fun finalizeMarketClose(now: LocalTime) {
when {
(AutoTradingManager.now.hour == 0 && AutoTradingManager.now.minute == 0 && (isSystemReadyToday || isSystemCleanedUpToday)) -> {
waitTime = 10.0
isSystemReadyToday = false
isSystemCleanedUpToday = false
}
(AutoTradingManager.now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> {
waitTime = 3.0
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
KisWebSocketManager.disconnect()
tryRefreshToken()
}
}
(AutoTradingManager.now.isAfter(LocalTime.of(18, 20))) -> {
try {
waitTime = 5.0
println("current SystemCleanedUpToday is $isSystemCleanedUpToday")
if (!isSystemCleanedUpToday) {
println("🌙 [System] 업무 종료 및 자원 정리 시작...")
SystemSleepPreventer.sleepDisplay() // 모니터 끄기
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0) // 즉시 닫기
if (LlamaServerManager.stopAll()) {
isSystemCleanedUpToday = true
}
}
println("✅ [System] 오늘의 모든 정리가 완료되었습니다.")
} catch (e: Exception) {
}
}
(AutoTradingManager.now.isAfter(LocalTime.of(18, 15)) && AutoTradingManager.now.minute % 15 == 0) -> {
try {
waitTime = 5.0
SystemSleepPreventer.sleepDisplay() // 모니터 끄기
} catch (e: Exception) {
}
}
else -> {
waitTime = 5.0
}
}
}
fun addToReanalysis(stock: RankingStock) {
val count = retryCountMap.getOrDefault(stock.code, 0)
if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지
retryCountMap[stock.code] = count + 1
reanalysisList.add(stock)
// println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
}
}
val failList = arrayListOf<String>()
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(ONE_STOCK_ALYSIS_TIME) {
val corpInfo = DartCodeManager.getCorpCode(stock.code)
if (corpInfo?.cName.isNullOrEmpty()) {
print("-> 기업명을 못찾아서 제외 | ")
return@withTimeout
}
callback(TradingDecision().apply {
this.stockCode = stock.code
this.confidence = -1.0
this.stockName = stock.name
}, false)
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
val today = dailyData.lastOrNull() ?: null
if (today == null) {
failList.add(stock.code)
print("-> 금일 금액 조회 실패 | ")
return@withTimeout
}
val currentPrice = today.stck_prpr.toDouble()
if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
return@withTimeout
}
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
val analyzer = coroutineScope {
val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) }
TechnicalAnalyzer().apply {
this.daily = dailyData
this.min30 = min30.await()
this.weekly = weekly.await()
this.monthly = monthly.await()
}
}
if (analyzer.isValid()) {
RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess ->
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
}
}
println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})")
}
} catch (e: Exception) {
println("❌ [Stock Error] ${stock.name}: ${e.message}")
}
}
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
listOf(
// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, true).getOrDefault(emptyList()) },
).awaitAll().flatten()
}
private fun restartLoop() {
discoveryJob?.cancel()
startAutoDiscoveryLoop()
}
private suspend fun waitForNextCycle(minutes: Double) {
println("💤 대기 모드 진입... $minutes")
val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L)
try {
BrowserManager.closeIfIdle(0) // 즉시 닫기
} catch (e: Exception) {
}
while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
println("💤 대기 모드 상태 확인...$minutes")
delay(if(minutes > 3.0 ) 10000 else 1000)
}
}
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap()
activeTrades.forEach { trade ->
try {
if (!realHoldings.containsKey(trade.code)) {
DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED)
return@forEach
}
// 마감 정리 로직 (필요 시 주석 해제하여 사용)
println("📢 [마감 정리 체크] ${trade.name}")
} catch (e: Exception) {
println("⚠️ [마감 에러] ${trade.name}: ${e.message}")
}
delay(200)
}
}
fun stopDiscovery() {
discoveryJob?.cancel()
discoveryJob = null
println("🛑 [AutoTrading] 자동 발굴 중단됨")
scope.launch {
onMarketClosed?.invoke()
println("💤 대기 모드 진입... $5.0")
val endWait = System.currentTimeMillis() + (5.0 * 60 * 1000L)
BrowserManager.closeIfIdle(0) // 즉시 닫기
while (System.currentTimeMillis() < endWait) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
println("💤 대기 모드 상태 확인...")
delay(if(5.0 > 3.0 ) 10000 else 1000)
}
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0)
LlamaServerManager.stopAll() // AI 서버 완전 종료
TradingLogStore.clear()
onMarketClosed?.invoke()
}
}
fun checkAndRestart() {
if (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
startAutoDiscoveryLoop()
} else {
}
}
}
data class Candle(
val timestamp: Long,
val open: Double,
val high: Double,
val low: Double,
val close: Double,
val volume: Double
)
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,
val allocationRate: 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,
allocationRate = ConfigIndex.GRADE_5_ALLOCATIONRATE,
),
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,
allocationRate = ConfigIndex.GRADE_4_ALLOCATIONRATE,
),
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,
allocationRate = ConfigIndex.GRADE_3_ALLOCATIONRATE,
),
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,
allocationRate = ConfigIndex.GRADE_2_ALLOCATIONRATE,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2,
profitGuide = ConfigIndex.GRADE_1_PROFIT,
buyGuide = ConfigIndex.GRADE_1_BUY,
allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE,
)
}