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-08 14:18:09 +09:00
import analyzer.AdvancedTradeAssistant
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
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 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-04-08 14:18:09 +09:00
import model.UnifiedStockHolding
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-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
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
// 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
2026-04-08 14:18:09 +09:00
if ( isTradingDay && now . isAfter ( H07M50 ) && 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 ( !is TradingDay ) {
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
2026-04-08 14:18:09 +09:00
// val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
2026-04-07 18:07:18 +09:00
// 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}")
// }
// }
// }
// }
// }
2026-04-08 14:18:09 +09:00
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 )
TradingLogStore . addLog ( decision , " BUY " , decision . summary ( ) )
excuteTrade (
decision = decision ,
orderQty = calculatedQty . toString ( ) ,
profitRate1 = KisSession . config . getValues ( ConfigIndex . PROFIT _INDEX ) * KisSession . config . getValues ( grade . profitGuide ) ,
investmentGrade = grade
)
} else if ( decision . decision . equals ( " RETRY " ) || decision . confidence >= 60.0 ) { // 아까운 종목만 재분석
addToReanalysis ( RankingStock ( decision . stockCode , decision . stockName ) )
}
2026-03-13 16:37:53 +09:00
}
}
fun getInvestmentGrade (
ts : TradingDecision ,
totalScore : Double ,
2026-04-08 14:18:09 +09:00
confidence : Double ,
finScore100 : Double // 💡 [수정1] 컴파일 에러 방지용 파라미터 추가
2026-03-13 16:37:53 +09:00
) : InvestmentGrade {
2026-04-07 17:32:21 +09:00
val minScore = KisSession . config . getValues ( ConfigIndex . MIN _PURCHASE _SCORE _INDEX )
2026-04-08 14:18:09 +09:00
val minConfidence = minScore
2026-04-07 17:32:21 +09:00
if ( totalScore < ( minScore * 0.8 ) || confidence < minConfidence ) {
2026-04-08 14:18:09 +09:00
return InvestmentGrade . LEVEL _0 _SPECULATIVE
2026-04-07 17:32:21 +09:00
}
val shortAvg = ( ts . ultraShortScore + ts . shortTermScore ) / 2.0
val midLongAvg = ( ts . midTermScore + ts . longTermScore ) / 2.0
val isOverheated = ts . analyzer ?. isOverheatedStock ( ) ?: true
2026-04-08 14:18:09 +09:00
// 1. 기본 등급 산정
var rawGrade = when {
midLongAvg >= 70.0 -> {
if ( shortAvg >= 75.0 ) InvestmentGrade . LEVEL _5 _STRONG _RECOMMEND
else if ( shortAvg >= 65.0 ) InvestmentGrade . LEVEL _4 _BALANCED _RECOMMEND
2026-04-07 17:32:21 +09:00
else InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND
}
2026-04-08 14:18:09 +09:00
midLongAvg >= 60.0 -> {
if ( shortAvg >= 70.0 ) InvestmentGrade . LEVEL _4 _BALANCED _RECOMMEND
else if ( shortAvg >= 60.0 ) InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND
2026-04-07 17:32:21 +09:00
else InvestmentGrade . LEVEL _2 _HIGH _RISK
}
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-08 14:18:09 +09:00
// 💡 [수정2] 누락되었던 우량주 눌림목 프리미엄 & 잡주 투매 회피 로직 추가
val isHealthy = finScore100 >= 70.0
val isPullback = midLongAvg >= 75.0 && shortAvg <= 45.0
if ( isHealthy && isPullback ) {
rawGrade = when ( rawGrade ) {
InvestmentGrade . LEVEL _1 _SPECULATIVE ,
InvestmentGrade . LEVEL _2 _HIGH _RISK -> InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND
InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND -> InvestmentGrade . LEVEL _4 _BALANCED _RECOMMEND
else -> rawGrade
}
} else if ( !is Healthy && isPullback ) {
rawGrade = when ( rawGrade ) {
InvestmentGrade . LEVEL _5 _STRONG _RECOMMEND ,
InvestmentGrade . LEVEL _4 _BALANCED _RECOMMEND ,
InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND -> InvestmentGrade . LEVEL _1 _SPECULATIVE
InvestmentGrade . LEVEL _2 _HIGH _RISK ,
InvestmentGrade . LEVEL _1 _SPECULATIVE -> InvestmentGrade . LEVEL _0 _SPECULATIVE
else -> InvestmentGrade . LEVEL _0 _SPECULATIVE
}
}
2026-04-07 17:32:21 +09:00
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
}
}
2026-04-08 14:18:09 +09:00
fun excuteTrade ( decision : TradingDecision , orderQty : String , profitRate1 : Double ? , investmentGrade : InvestmentGrade = InvestmentGrade . LEVEL _2 _HIGH _RISK ) {
2026-03-13 16:37:53 +09:00
scope . launch {
var basePrice = decision . currentPrice
val tickSize = MarketUtil . getTickSize ( basePrice )
2026-04-08 14:18:09 +09:00
// 등급별 가이드에 따라 매수 호가 설정
2026-03-13 16:37:53 +09:00
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 )
2026-04-08 14:18:09 +09:00
. onSuccess { realOrderNo ->
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
println ( " [ ${investmentGrade.displayName} ] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice " )
TradingLogStore . addLog ( decision , " BUY " , " [ ${investmentGrade.displayName} ] 주문 성공: $realOrderNo " )
2026-03-13 16:37:53 +09:00
2026-04-08 14:18:09 +09:00
// 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장)
val sRate = - 1.5
2026-03-13 16:37:53 +09:00
var tax = KisSession . config . getValues ( ConfigIndex . TAX _INDEX )
2026-04-08 14:18:09 +09:00
// 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기
val effectiveProfitRate = ( profitRate1 ?: KisSession . config . getValues ( ConfigIndex . PROFIT _INDEX ) ) + tax
2026-03-13 16:37:53 +09:00
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 ,
2026-04-08 14:18:09 +09:00
profitRate = effectiveProfitRate ,
2026-03-13 16:37:53 +09:00
stopLossRate = sRate ,
targetPrice = calculatedTarget ,
stopLossPrice = calculatedStop ,
status = " PENDING_BUY " ,
isDomestic = true
) )
syncAndExecute ( realOrderNo )
2026-04-08 14:18:09 +09:00
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
TradingLogStore . addLog ( decision , " BUY " , " [ ${investmentGrade.displayName} ] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)} %): $realOrderNo " )
2026-03-13 16:37:53 +09:00
}
. 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
2026-04-08 14:18:09 +09:00
val absoluteMinRate = KisSession . config . getValues ( ConfigIndex . TAX _INDEX ) + 0.05
val finalProfitRate = maxOf ( dbItem . profitRate , absoluteMinRate )
2026-03-13 16:37:53 +09:00
2026-04-08 14:18:09 +09:00
// 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
2026-03-13 16:37:53 +09:00
val finalTargetPrice = MarketUtil . roundToTickSize ( actualBuyPrice * ( 1 + finalProfitRate / 100.0 ) )
2026-04-08 14:18:09 +09:00
2026-03-13 16:37:53 +09:00
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 (
2026-04-08 14:18:09 +09:00
holding . name ,
holding . code ,
" ${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상 "
)
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-08 14:18:09 +09:00
} else {
analyzeDeepLossHoldingsAfterMarket ( holding )
2026-04-03 17:02:42 +09:00
}
delay ( 300 ) // API 호출 부하 방지
}
}
}
suspend fun resumePendingSellOrders ( tradeService : KisTradeService , balance : UnifiedBalance ) {
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-08 14:18:09 +09:00
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 ) {
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
2026-04-03 17:02:42 +09:00
} else {
2026-04-08 14:18:09 +09:00
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 (
2026-04-03 17:02:42 +09:00
holding . code ,
2026-04-08 14:18:09 +09:00
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} ] "
2026-04-03 17:02:42 +09:00
)
}
2026-04-08 14:18:09 +09:00
} else {
analyzeDeepLossHoldingsAfterMarket ( holding )
2026-03-27 17:54:21 +09:00
}
2026-04-08 14:18:09 +09:00
delay ( 200 ) // API 호출 부하 방지
2026-02-19 15:47:31 +09:00
}
2026-04-08 14:18:09 +09:00
}
2026-04-06 09:44:39 +09:00
// }
2026-02-19 15:47:31 +09:00
}
2026-04-08 14:18:09 +09:00
private suspend fun analyzeDeepLossHoldingsAfterMarket ( holding : UnifiedStockHolding ) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
val now = LocalTime . now ( )
val currentMinute = now . minute
if ( ( now . hour == 8 || now . hour == 16 || now . hour == 17 ) ) {
val profit = holding . profitRate . toDouble ( )
val lossThreshold = - 5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다)
if ( profit <= lossThreshold ) {
println ( " 🔍 [손실 종목 분석] ${holding.name} (수익률: $profit %) - 가이드 산출 중... " )
// 차트 데이터 빠르게 가져오기 (일봉 위주로 큰 추세만 확인)
val dailyData = KisTradeService . fetchPeriodChartData ( holding . code , " D " , true ) . getOrNull ( )
if ( ! dailyData . isNullOrEmpty ( ) ) {
val analyzer = TechnicalAnalyzer ( ) . apply { this . daily = dailyData }
val currentPrice = holding . currentPrice . toDouble ( )
// 1. 볼린저 밴드 하단선 (통계적 바닥) 확인
val lowerBand = AdvancedTradeAssistant . calculateBollingerLowerBand ( dailyData )
// 2. RSI 확인 (과매도 투매 상태인지)
val rsiDaily = analyzer . calculateRSI ( dailyData )
// 3. 중기 추세 확인 (최근 20일 기준 10% 이상 하락했는지)
val isTrendBroken = analyzer . calculateChange ( dailyData . takeLast ( 20 ) ) < - 10.0
var advice = " "
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
if ( lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0 ) {
advice = " 📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도( ${"%.1f".format(rsiDaily)} ). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려) "
}
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
else if ( isTrendBroken && currentPrice > lowerBand * 1.1 ) {
advice = " 🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다. "
}
// 🟡 [관망] 어정쩡하게 물려있는 상태
else {
advice = " ⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다. "
}
// 분석 결과를 UI 로그에 띄워 대표님이 확인할 수 있게 함
TradingLogStore . addNotice (
" 보유주식[ ${holding.name} ] " ,
holding . code ,
" 수익률 심각( $profit %) -> $advice " ,
)
}
} else {
// -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김
// TradingLogStore.addAnalyzer("보유주식[${holding.name}]", holding.code, "수익률 미달 대기중 (${profit}%)")
}
}
}
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-04-08 14:18:09 +09:00
val H08M00 = LocalTime . of ( 8 , 0 )
2026-04-08 08:25:49 +09:00
val H08M45 = LocalTime . of ( 8 , 45 )
2026-04-08 14:18:09 +09:00
val H07M50 = LocalTime . of ( 7 , 50 )
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-04-08 14:18:09 +09:00
now . isAfter ( H18 ) || now . isBefore ( H08M00 ) -> {
2026-03-26 13:48:26 +09:00
prepareMarketOpen ( now )
}
2026-04-08 14:18:09 +09:00
now . isBefore ( H18 ) && now . isAfter ( H08M00 ) -> {
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-04-08 14:18:09 +09:00
if ( now . isAfter ( H18 ) || now . isBefore ( H07M50 ) ) {
2026-03-26 13:48:26 +09:00
println ( " 🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다. " )
onMarketClosed ?. invoke ( )
2026-04-08 14:18:09 +09:00
RagService . clearDailyCache ( )
2026-03-26 13:48:26 +09:00
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분에 다시 켜짐)
2026-04-08 14:18:09 +09:00
} else if ( now . isAfter ( H07M50 ) && now . isBefore ( H08M00 ) && !is SystemReadyToday ) {
2026-03-26 13:48:26 +09:00
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-08 08:25:49 +09:00
if ( now . isBefore ( H16 ) && now . isAfter ( H08M45 ) ) {
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-08 08:25:49 +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-08 08:23:39 +09:00
else if ( ( now . hour == 8 || 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 ) ) && !is SystemReadyToday ) -> {
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 ( !is SystemCleanedUpToday ) {
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 ( !is Running ( ) ) {
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 (
2026-04-08 14:18:09 +09:00
displayName = " 최상급 스윙/가치형 " ,
description = " 중장기 추세가 완벽하며 단기 파동까지 일치하는 매우 안정적인 매수 추천 " ,
2026-04-02 14:05:14 +09:00
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 (
2026-04-08 14:18:09 +09:00
displayName = " 우량 균형형 " ,
description = " 기본적인 펀더멘털과 중장기 추세가 양호하여 꾸준한 우상향이 기대되는 종목 " ,
2026-04-02 14:05:14 +09:00
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 (
2026-04-08 14:18:09 +09:00
displayName = " 보수적 혼합형 " ,
description = " 중장기 지표는 양호하나 단기 변동성이 있거나, 반대로 단기 수급만 몰린 팽팽한 상태 " ,
2026-04-02 14:05:14 +09:00
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 (
2026-04-08 14:18:09 +09:00
displayName = " 고위험 단기 모멘텀 " ,
description = " 중장기 추세는 약하지만, 뉴스나 테마로 인해 단기 수급이 강력하게 붙은 스캘핑 대상 " ,
2026-04-02 14:05:14 +09:00
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 (
2026-04-08 14:18:09 +09:00
displayName = " 순수 투기/초단타 " ,
description = " 재무 및 중장기 지표 무관, 오직 초단기 분봉과 에너지만 살아있는 극도의 투기적 진입 " ,
2026-04-02 14:05:14 +09:00
shortWeight = 1.0 ,
midWeight = 0.2 ,
longWeight = 0.2 ,
profitGuide = ConfigIndex . GRADE _1 _PROFIT ,
buyGuide = ConfigIndex . GRADE _1 _BUY ,
allocationRate = ConfigIndex . GRADE _1 _ALLOCATIONRATE ,
2026-04-08 14:18:09 +09:00
) ,
LEVEL _0 _SPECULATIVE (
displayName = " 매수 금지 (관망) " ,
description = " 최소 신뢰도(Confidence) 미달로 시스템 통과 실패 " ,
shortWeight = 0.1 ,
midWeight = 0.1 ,
longWeight = 0.1 ,
profitGuide = ConfigIndex . GRADE _1 _PROFIT , // 더미 데이터
buyGuide = ConfigIndex . GRADE _1 _BUY ,
allocationRate = ConfigIndex . GRADE _1 _ALLOCATIONRATE ,
2026-04-02 14:05:14 +09:00
)
}