atrade/src/main/kotlin/service/AutoTradingManager.kt

1035 lines
50 KiB
Kotlin
Raw Normal View History

2026-01-22 16:21:18 +09:00
package service
2026-03-13 16:37:53 +09:00
import AutoTradeItem
2026-03-27 17:54:21 +09:00
import Defines.AUTOSELL
import Defines.BLACKLISTEDSTOCKCODES
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
2026-03-17 10:50:13 +09:00
import network.TradingDecision
2026-03-13 17:34:48 +09:00
import TradingLogStore
2026-04-07 17:32:21 +09:00
import analyzer.TechnicalAnalyzer
2026-03-20 17:55:27 +09:00
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
2026-03-26 14:42:39 +09:00
import androidx.compose.runtime.remember
2026-03-20 17:55:27 +09:00
import androidx.compose.runtime.setValue
2026-03-13 10:41:10 +09:00
import getLlamaBinPath
2026-01-22 16:21:18 +09:00
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
2026-02-03 18:07:18 +09:00
import kotlinx.coroutines.Job
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.SupervisorJob
2026-02-05 14:26:02 +09:00
import kotlinx.coroutines.TimeoutCancellationException
2026-02-03 18:07:18 +09:00
import kotlinx.coroutines.async
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
2026-02-03 18:07:18 +09:00
import kotlinx.coroutines.delay
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.isActive
2026-01-22 16:21:18 +09:00
import kotlinx.coroutines.launch
2026-02-05 14:26:02 +09:00
import kotlinx.coroutines.withTimeout
2026-02-19 15:47:31 +09:00
import kotlinx.serialization.Serializable
2026-01-22 16:21:18 +09:00
import model.CandleData
2026-02-19 15:47:31 +09:00
import model.ConfigIndex
2026-03-13 16:37:53 +09:00
import model.ExecutionData
2026-02-19 15:47:31 +09:00
import model.KisSession
2026-02-06 17:53:17 +09:00
import model.RankingStock
2026-02-03 18:07:18 +09:00
import model.RankingType
2026-02-19 16:20:15 +09:00
import model.UnifiedBalance
2026-02-05 15:37:11 +09:00
import network.DartCodeManager
2026-03-13 10:41:10 +09:00
import network.KisAuthService
2026-02-03 18:07:18 +09:00
import network.KisTradeService
2026-03-13 10:41:10 +09:00
import network.KisWebSocketManager
2026-03-17 10:50:13 +09:00
import network.RagService
2026-03-16 17:07:25 +09:00
import network.StockUniverseLoader
2026-03-26 14:42:39 +09:00
import org.jetbrains.skia.ImageFilter
2026-02-19 15:47:31 +09:00
import util.MarketUtil
2026-04-03 18:09:14 +09:00
import java.time.LocalDate
2026-01-22 17:56:31 +09:00
import java.time.LocalDateTime
2026-01-22 16:21:18 +09:00
import java.time.LocalTime
2026-01-22 17:56:31 +09:00
import java.time.ZoneId
import java.time.format.DateTimeFormatter
2026-02-06 17:53:17 +09:00
import java.util.concurrent.atomic.AtomicLong
2026-01-22 16:26:29 +09:00
import kotlin.collections.List
2026-01-22 16:21:18 +09:00
2026-01-22 17:56:31 +09:00
import kotlin.math.*
2026-01-22 16:21:18 +09:00
// service/AutoTradingManager.kt
2026-01-23 17:05:09 +09:00
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
2026-01-22 16:21:18 +09:00
object AutoTradingManager {
2026-03-27 17:54:21 +09:00
2026-02-06 17:53:17 +09:00
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
2026-02-03 18:07:18 +09:00
private var discoveryJob: Job? = null
2026-01-22 16:21:18 +09:00
2026-02-06 17:53:17 +09:00
// 모니터링을 위한 상태 변수
private val lastTickTime = AtomicLong(System.currentTimeMillis())
private var watchdogJob: Job? = null
2026-03-16 17:07:25 +09:00
private const val CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분
2026-02-06 17:53:17 +09:00
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
2026-03-16 17:07:25 +09:00
private const val STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
private const val ONE_STOCK_ALYSIS_TIME = 180000L
2026-02-06 17:53:17 +09:00
fun isRunning(): Boolean = discoveryJob?.isActive == true
2026-02-10 15:08:52 +09:00
private var remainingCandidates = mutableListOf<RankingStock>()
2026-02-13 13:49:40 +09:00
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
2026-02-12 15:31:34 +09:00
private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>()
2026-03-20 17:55:27 +09:00
var shouldShowFullWindow by mutableStateOf(false)
2026-03-26 14:42:39 +09:00
var llmAnalyser by mutableStateOf(false)
var llmNews by mutableStateOf(false)
var tradeToken by mutableStateOf(false)
var webSocketConnect by mutableStateOf(false)
2026-03-27 10:59:59 +09:00
var testFlag = false
2026-03-30 16:00:51 +09:00
2026-03-20 17:55:27 +09:00
fun startBackgroundScheduler() {
2026-03-27 10:59:59 +09:00
scope.launch {
while (isActive) {
2026-04-03 18:09:14 +09:00
val seoulZone = ZoneId.of("Asia/Seoul")
2026-03-27 10:59:59 +09:00
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
2026-04-03 18:09:14 +09:00
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) {
2026-03-27 10:59:59 +09:00
shouldShowFullWindow = true
SystemSleepPreventer.wakeDisplay()
2026-03-30 16:00:51 +09:00
} else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) {
SystemSleepPreventer.sleepDisplay()
2026-03-27 10:59:59 +09:00
}
2026-04-03 18:09:14 +09:00
if (!isTradingDay) {
checkTime = 60_000 * 30L
}
delay(checkTime) // 1분마다 체크
2026-03-27 10:59:59 +09:00
}
}
2026-03-20 17:55:27 +09:00
}
2026-03-13 16:37:53 +09:00
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) {
// 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
TradingLogStore.addLog(completeTradingDecision)
println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}")
2026-03-23 10:54:54 +09:00
if (completeTradingDecision.confidence < 10) {
addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
2026-04-02 14:05:14 +09:00
TradingLogStore.addLog(completeTradingDecision,"RETRY","분석 신뢰도 오류 인지로 재분석 대기열에 추가")
2026-03-23 10:54:54 +09:00
}else if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) {
2026-03-13 16:37:53 +09:00
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) {
2026-04-07 17:32:21 +09:00
var investmentGrade = completeTradingDecision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
2026-03-13 16:37:53 +09:00
val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
2026-04-02 15:22:38 +09:00
val gradeRate = KisSession.config.getValues(investmentGrade.allocationRate)
2026-03-13 16:37:53 +09:00
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 전달)
2026-03-13 17:34:48 +09:00
excuteTrade(
2026-03-13 16:37:53 +09:00
decision = completeTradingDecision,
orderQty = min(calculatedQty, maxQty).toString(),
profitRate1 = finalMargin,
investmentGrade = investmentGrade,
)
2026-04-02 15:22:38 +09:00
} else if(totalScore >= (minScore * 0.9) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.9)) {
2026-03-13 17:34:48 +09:00
addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
2026-04-02 14:05:14 +09:00
TradingLogStore.addLog(completeTradingDecision,"RETRY","✋ [관망] 토탈 스코어[$totalScore] 또는 신뢰도[${completeTradingDecision.confidence}] 미달 이나 약간의 오차로 재분석 대기열에 추가")
2026-03-13 16:37:53 +09:00
} else {
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
}
}
2026-03-23 10:54:54 +09:00
if (completeTradingDecision?.decision?.contains("매수") == true) {
completeTradingDecision.decision = "BUY"
}
2026-03-13 16:37:53 +09:00
when (completeTradingDecision?.decision) {
2026-03-23 10:54:54 +09:00
"BUY","매수" -> {
2026-03-13 16:37:53 +09:00
append = buyWeight
2026-04-02 15:22:38 +09:00
TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 : ${completeTradingDecision?.reason}")
2026-03-13 16:37:53 +09:00
resultCheck(completeTradingDecision)
}
2026-03-26 14:42:39 +09:00
"SELL" -> {
2026-04-02 15:22:38 +09:00
TradingLogStore.addLog(completeTradingDecision,"SELL","[$stockCode] 매도 추천 : ${completeTradingDecision?.reason}")
2026-03-26 14:42:39 +09:00
println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
}
2026-03-13 16:37:53 +09:00
"HOLD" -> {
append = 0.0
2026-04-02 15:22:38 +09:00
TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 : ${completeTradingDecision?.reason}")
2026-03-13 16:37:53 +09:00
resultCheck(completeTradingDecision)
}
else -> {
append = 0.0
2026-04-02 15:22:38 +09:00
println("[$stockCode] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason}")
2026-03-13 16:37:53 +09:00
}
}
}
}
}
2026-04-07 17:32:21 +09:00
val MIN_CONFIDENCE = 60.0 // 최소 신뢰도
2026-03-13 16:37:53 +09:00
var append = 0.0
fun getInvestmentGrade(
ts: TradingDecision,
totalScore: Double,
confidence: Double
): InvestmentGrade {
2026-04-07 17:32:21 +09:00
// [개선] 하드코딩된 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
}
2026-03-13 16:37:53 +09:00
2026-04-07 17:32:21 +09:00
// [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
}
2026-03-13 16:37:53 +09:00
2026-04-07 17:32:21 +09:00
// [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태
else -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
else InvestmentGrade.LEVEL_1_SPECULATIVE
}
}
2026-03-13 16:37:53 +09:00
2026-04-07 17:32:21 +09:00
// 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
2026-03-13 16:37:53 +09:00
}
}
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")
2026-04-01 14:35:56 +09:00
if (it.message?.contains("주문가능금액을 초과") == true) {
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
2026-04-02 14:05:14 +09:00
TradingLogStore.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가")
2026-04-01 14:35:56 +09:00
} else {
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
}
2026-03-13 16:37:53 +09:00
}
}
}
2026-03-13 17:34:48 +09:00
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)
}
}
2026-03-13 16:37:53 +09:00
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)
2026-03-13 17:34:48 +09:00
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
2026-03-13 16:37:53 +09:00
executionCache.remove(orderNo)
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
2026-03-13 17:34:48 +09:00
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}")
2026-03-13 16:37:53 +09:00
}
} else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
2026-03-13 17:34:48 +09:00
TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리")
2026-03-13 16:37:53 +09:00
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
}
}
} finally {
processingIds.remove(orderNo)
}
}
2026-02-06 17:53:17 +09:00
/**
* 자동 발굴 루프 시작 Watchdog 실행
*/
2026-03-13 16:37:53 +09:00
fun startAutoDiscoveryLoop() {
2026-02-06 17:53:17 +09:00
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분간 응답 없음). 강제 재시작합니다.")
2026-03-13 16:37:53 +09:00
restartLoop()
2026-02-05 15:37:11 +09:00
}
}
}
2026-02-06 17:53:17 +09:00
// 2. 메인 루프 실행
2026-03-26 13:48:26 +09:00
runDiscoveryLoop(globalCallback)
2026-02-06 17:53:17 +09:00
}
2026-02-03 18:07:18 +09:00
2026-04-03 17:34:27 +09:00
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
2026-02-24 13:14:11 +09:00
balance.holdings.forEach { holding ->
2026-03-27 17:54:21 +09:00
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
2026-04-02 14:05:14 +09:00
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
2026-03-27 17:54:21 +09:00
} else {
2026-04-06 09:55:13 +09:00
println("sellingAfterMarketOnePrice")
2026-04-07 17:32:21 +09:00
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
2026-03-27 17:54:21 +09:00
var targetPrice = holding.currentPrice.toDouble()
2026-04-03 17:47:56 +09:00
TradingLogStore.addAfterMarketLog(
holding.name,
2026-03-27 17:54:21 +09:00
holding.code,
2026-04-03 17:47:56 +09:00
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
2026-03-27 17:54:21 +09:00
)
2026-04-03 17:47:56 +09:00
2026-04-07 17:32:21 +09:00
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}] "
)
}
2026-04-03 17:02:42 +09:00
}
delay(300) // API 호출 부하 방지
}
}
}
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
2026-04-06 09:53:46 +09:00
// if (isRunning()) return
2026-04-03 17:02:42 +09:00
val now = LocalTime.now()
val currentMinute = now.minute
2026-04-06 09:44:39 +09:00
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
2026-04-03 17:02:42 +09:00
println("resumePendingSellOrders")
balance.holdings.forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
2026-04-02 14:05:14 +09:00
TradingLogStore.addAnalyzer(
2026-04-03 17:02:42 +09:00
holding.name,
2026-04-02 14:05:14 +09:00
holding.code,
2026-04-03 17:02:42 +09:00
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
2026-04-02 14:05:14 +09:00
)
2026-04-03 17:02:42 +09:00
} else {
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
2026-04-06 09:13:05 +09:00
// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
2026-04-03 17:02:42 +09:00
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
var targetPrice = holding.currentPrice.toDouble()
val now = LocalTime.now()
val currentMinute = now.minute
var isBefore930 = false
2026-04-07 17:32:21 +09:00
if (now.hour == 9 && currentMinute < 30) {
targetPrice = targetPrice
isBefore930 = true
} else {
2026-04-03 17:02:42 +09:00
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
2026-04-07 17:32:21 +09:00
}
2026-04-03 17:02:42 +09:00
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 호출 부하 방지
2026-03-27 17:54:21 +09:00
}
2026-02-19 15:47:31 +09:00
}
2026-04-06 09:44:39 +09:00
// }
2026-02-19 15:47:31 +09:00
}
2026-03-13 10:41:10 +09:00
var isSystemReadyToday = false
var isSystemCleanedUpToday = false
private var lastRetryTime = 0L
val binPath = getLlamaBinPath()
2026-02-19 15:47:31 +09:00
2026-03-13 10:41:10 +09:00
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()) {
2026-03-27 17:45:51 +09:00
LlamaServerManager.startServer(binPath, config.modelPath,port = LLM_PORT)
2026-03-13 10:41:10 +09:00
}
if (config.embedModelPath.isNotEmpty()) {
2026-03-27 17:45:51 +09:00
LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT)
2026-03-13 10:41:10 +09:00
}
KisWebSocketManager.connect()
isSystemReadyToday = true
2026-03-20 17:55:27 +09:00
shouldShowFullWindow = true
2026-03-13 10:41:10 +09:00
} else {
println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.")
}
}
} catch (e: Exception) {}
}
2026-03-19 11:41:21 +09:00
var onMarketClosed: (() -> Unit)? = null
2026-03-13 10:41:10 +09:00
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
var currentTimeMillis = System.currentTimeMillis()
var waitTime = 0.2
2026-04-03 17:02:42 +09:00
val H16 = LocalTime.of(16, 0)
2026-03-26 15:40:46 +09:00
val H18 = LocalTime.of(18, 0)
2026-03-26 13:48:26 +09:00
val H08M35 = LocalTime.of(8, 35)
2026-03-19 17:00:53 +09:00
val H08M30 = LocalTime.of(8, 30)
2026-03-26 13:48:26 +09:00
private fun runDiscoveryLoop(callback: TradingDecisionCallback) {
2026-02-03 18:07:18 +09:00
discoveryJob = scope.launch {
2026-02-06 17:53:17 +09:00
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
while (isActive) {
2026-02-03 18:07:18 +09:00
try {
2026-03-13 10:41:10 +09:00
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
currentTimeMillis = System.currentTimeMillis()
2026-02-06 17:53:17 +09:00
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
2026-03-13 10:41:10 +09:00
when {
2026-03-26 15:40:46 +09:00
now.isAfter(H18) || now.isBefore(H08M35) -> {
2026-03-26 13:48:26 +09:00
prepareMarketOpen(now)
}
2026-03-26 15:40:46 +09:00
now.isBefore(H18) && now.isAfter(H08M35) -> {
2026-03-13 10:41:10 +09:00
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()}")
2026-03-26 15:40:46 +09:00
if (now.isAfter(H18)) {
2026-03-26 13:48:26 +09:00
executeClosingLiquidation(KisTradeService)
2026-03-13 10:41:10 +09:00
} else {
2026-03-26 13:48:26 +09:00
executeMarketLoop()
2026-03-13 10:41:10 +09:00
}
2026-02-09 15:32:31 +09:00
}
2026-02-03 18:07:18 +09:00
}
2026-03-26 13:48:26 +09:00
//
// //장외
// now.isAfter(H16) || now.isBefore(H08M35) -> {
// finalizeMarketClose(now)
// }
2026-03-13 10:41:10 +09:00
else ->{
waitTime = 3.0
}
2026-02-03 18:07:18 +09:00
}
2026-02-06 17:53:17 +09:00
} catch (e: TimeoutCancellationException) {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
} catch (e: Exception) {
println("⚠️ [Loop Error] ${e.message}")
2026-02-24 13:14:11 +09:00
delay(1500)
2026-02-06 17:53:17 +09:00
}
2026-03-13 10:41:10 +09:00
waitForNextCycle(waitTime)
2026-02-06 17:53:17 +09:00
}
}
}
2026-02-04 14:52:09 +09:00
2026-03-26 13:48:26 +09:00
suspend fun prepareMarketOpen(now : LocalTime) {
2026-03-26 15:40:46 +09:00
if (now.isAfter(H18) || now.isBefore(H08M30)) {
2026-03-26 13:48:26 +09:00
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
onMarketClosed?.invoke()
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0)
LlamaServerManager.stopAll() // AI 서버 완전 종료
TradingLogStore.clear()
2026-03-26 15:40:46 +09:00
isSystemReadyToday = false
shouldShowFullWindow = false
2026-03-26 13:48:26 +09:00
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if (now.isAfter(H08M30) && now.isBefore(H08M35) && !isSystemReadyToday) {
if (MarketUtil.canTradeToday()) {
2026-03-26 18:12:08 +09:00
SystemSleepPreventer.wakeDisplay()
2026-03-26 15:40:46 +09:00
shouldShowFullWindow = true
2026-03-26 13:48:26 +09:00
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true)
} else {
println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.")
delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여
}
}
}
var loadedTops = mutableListOf<Pair<String, String>>()
fun poll100Stocks(): List<Pair<String, String>> {
2026-04-06 09:44:39 +09:00
val count = minOf(loadedTops.size, 150)
2026-03-26 13:48:26 +09:00
if (count == 0) return emptyList()
// 앞의 100개를 복사
val batch = loadedTops.subList(0, count).toList()
// 원본에서 삭제 (이 작업이 큐의 pop/remove 역할을 합니다)
loadedTops.subList(0, count).clear()
return batch
}
2026-04-03 17:02:42 +09:00
suspend fun checkBalance(isMorning: Boolean = true) : UnifiedBalance? {
2026-04-03 17:34:27 +09:00
var balance : UnifiedBalance? = null
2026-04-06 09:55:13 +09:00
if (isMorning) {
2026-04-03 17:34:27 +09:00
balance = KisTradeService.fetchIntegratedBalance().getOrNull()
2026-04-03 17:02:42 +09:00
if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
2026-04-06 09:55:13 +09:00
return balance
} else {
}
return null
2026-04-02 14:05:14 +09:00
}
2026-03-27 17:54:21 +09:00
2026-04-02 14:05:14 +09:00
suspend fun executeMarketLoop() {
val balance = checkBalance()
2026-04-06 15:07:14 +09:00
// if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
2026-03-27 17:54:21 +09:00
2026-03-26 13:48:26 +09:00
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()
2026-04-07 17:32:21 +09:00
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
2026-04-03 17:02:42 +09:00
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)
2026-03-27 17:54:21 +09:00
}
2026-04-07 17:32:21 +09:00
// }
2026-04-02 14:05:14 +09:00
sellSchedule()
2026-03-26 13:48:26 +09:00
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
}
2026-04-02 14:05:14 +09:00
private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장
suspend fun sellSchedule() {
val now = LocalTime.now()
val currentMinute = now.minute
println("매도 스케줄 체크")
2026-04-06 09:53:46 +09:00
if (now.hour == 9 && (currentMinute % 10 == 1 || currentMinute % 10 == 7)) {
2026-04-02 14:05:14 +09:00
if (lastForceCheckMinute != currentMinute) {
TradingLogStore.addAnalyzer(" - ", " - ", "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.", true)
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
checkBalance()
lastForceCheckMinute = currentMinute // 실행 완료 기록
}
}
2026-04-07 17:32:21 +09:00
else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3)) {
2026-04-06 15:07:14 +09:00
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 // 실행 완료 기록
}
2026-04-03 17:02:42 +09:00
}
2026-04-02 14:05:14 +09:00
}
2026-03-26 13:48:26 +09:00
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()
}
}
2026-04-02 10:44:14 +09:00
(AutoTradingManager.now.isAfter(LocalTime.of(18, 20))) -> {
2026-03-26 13:48:26 +09:00
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
}
}
}
2026-02-12 15:31:34 +09:00
fun addToReanalysis(stock: RankingStock) {
val count = retryCountMap.getOrDefault(stock.code, 0)
2026-02-24 13:14:11 +09:00
if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지
2026-02-12 15:31:34 +09:00
retryCountMap[stock.code] = count + 1
reanalysisList.add(stock)
2026-03-16 17:07:25 +09:00
// println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
2026-02-12 15:31:34 +09:00
}
}
2026-03-16 17:07:25 +09:00
val failList = arrayListOf<String>()
2026-02-06 17:53:17 +09:00
private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) {
try {
2026-02-19 15:47:31 +09:00
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)
2026-02-06 17:53:17 +09:00
// 개별 종목 분석은 최대 2분으로 제한
2026-02-24 13:14:11 +09:00
withTimeout(ONE_STOCK_ALYSIS_TIME) {
2026-02-06 17:53:17 +09:00
val corpInfo = DartCodeManager.getCorpCode(stock.code)
if (corpInfo?.cName.isNullOrEmpty()) {
2026-02-13 15:40:20 +09:00
print("-> 기업명을 못찾아서 제외 | ")
2026-02-06 17:53:17 +09:00
return@withTimeout
}
2026-02-12 15:31:34 +09:00
callback(TradingDecision().apply {
this.stockCode = stock.code
this.confidence = -1.0
this.stockName = stock.name
}, false)
2026-02-04 14:52:09 +09:00
2026-02-06 17:53:17 +09:00
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
2026-02-13 15:40:20 +09:00
val today = dailyData.lastOrNull() ?: null
if (today == null) {
2026-03-16 17:07:25 +09:00
failList.add(stock.code)
2026-02-13 15:40:20 +09:00
print("-> 금일 금액 조회 실패 | ")
return@withTimeout
}
2026-02-06 17:53:17 +09:00
val currentPrice = today.stck_prpr.toDouble()
2026-02-19 15:47:31 +09:00
if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
2026-02-12 15:31:34 +09:00
return@withTimeout
}
2026-02-06 17:53:17 +09:00
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
2026-02-12 15:31:34 +09:00
2026-02-06 17:53:17 +09:00
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()
2026-02-04 14:52:09 +09:00
}
2026-02-06 17:53:17 +09:00
}
2026-03-23 10:54:54 +09:00
if (analyzer.isValid()) {
RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess ->
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
}
2026-02-03 18:07:18 +09:00
}
2026-02-06 17:53:17 +09:00
println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})")
2026-02-03 18:07:18 +09:00
}
2026-02-06 17:53:17 +09:00
} catch (e: Exception) {
println("❌ [Stock Error] ${stock.name}: ${e.message}")
}
}
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
2026-02-09 15:32:31 +09:00
2026-02-06 17:53:17 +09:00
listOf(
2026-02-09 15:32:31 +09:00
// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
2026-02-06 17:53:17 +09:00
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) },
2026-02-13 13:49:40 +09:00
async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) },
2026-02-06 17:53:17 +09:00
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) },
2026-02-09 15:32:31 +09:00
// async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
2026-02-06 17:53:17 +09:00
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
2026-02-10 15:08:52 +09:00
// async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
2026-02-09 15:32:31 +09:00
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()) },
2026-02-06 17:53:17 +09:00
).awaitAll().flatten()
}
2026-03-13 16:37:53 +09:00
private fun restartLoop() {
2026-02-06 17:53:17 +09:00
discoveryJob?.cancel()
2026-03-13 16:37:53 +09:00
startAutoDiscoveryLoop()
2026-02-06 17:53:17 +09:00
}
2026-02-13 13:49:40 +09:00
private suspend fun waitForNextCycle(minutes: Double) {
2026-03-13 10:41:10 +09:00
println("💤 대기 모드 진입... $minutes")
2026-02-06 17:53:17 +09:00
val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L)
2026-03-26 18:12:08 +09:00
try {
BrowserManager.closeIfIdle(0) // 즉시 닫기
} catch (e: Exception) {
}
2026-02-06 17:53:17 +09:00
while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
2026-04-02 10:44:14 +09:00
println("💤 대기 모드 상태 확인...$minutes")
2026-03-13 10:41:10 +09:00
delay(if(minutes > 3.0 ) 10000 else 1000)
2026-02-06 17:53:17 +09:00
}
}
2026-02-20 15:21:38 +09:00
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)
}
}
2026-01-22 16:21:18 +09:00
2026-02-03 18:07:18 +09:00
fun stopDiscovery() {
discoveryJob?.cancel()
discoveryJob = null
println("🛑 [AutoTrading] 자동 발굴 중단됨")
2026-03-19 17:00:53 +09:00
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()
}
2026-02-03 18:07:18 +09:00
}
2026-01-22 16:21:18 +09:00
2026-03-13 16:37:53 +09:00
fun checkAndRestart() {
2026-02-06 17:53:17 +09:00
if (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
2026-03-13 16:37:53 +09:00
startAutoDiscoveryLoop()
2026-02-06 17:53:17 +09:00
} else {
2026-01-22 16:21:18 +09:00
2026-02-06 17:53:17 +09:00
}
2026-01-22 16:21:18 +09:00
}
2026-02-06 17:53:17 +09:00
2026-01-22 16:21:18 +09:00
}
2026-02-10 15:08:52 +09:00
2026-01-22 17:56:31 +09:00
data class Candle(
val timestamp: Long,
val open: Double,
val high: Double,
val low: Double,
val close: Double,
val volume: Double
)
2026-04-02 14:05:14 +09:00
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,
)
}