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.BLACKLISTEDSTOCKCODES
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
2026-03-13 17:34:48 +09:00
import TradingLogStore
2026-05-19 13:33:59 +09:00
import TradingLogStore.noticeFilter
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-04-16 15:48:23 +09:00
import model.TradingDecision
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-05-26 11:20:37 +09:00
import network.RagService.isSafetyBeltStockCodes
2026-03-16 17:07:25 +09:00
import network.StockUniverseLoader
2026-08-03 11:53:29 +09:00
import okhttp3.internal.wait
2026-04-16 15:48:23 +09:00
import report.TradingReportManager
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-04-08 15:33:07 +09:00
import kotlin.collections.filter
2026-06-08 14:45:15 +09:00
import kotlin.math.abs
2026-06-16 13:07:38 +09:00
import kotlin.math.max
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-05-13 11:37:57 +09:00
var CYCLE _TIMEOUT = KisSession . tradeConfig . CYCLE_TIMEOUT
var WATCHDOG _CHECK_INTERVAL = KisSession . tradeConfig . WATCHDOG_CHECK_INTERVAL
var STUCK _THRESHOLD = KisSession . tradeConfig . STUCK_THRESHOLD
var ONE _STOCK_ALYSIS_TIME = KisSession . tradeConfig . ONE_STOCK_ALYSIS_TIME
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 ) {
val now = LocalTime . now ( ZoneId . of ( "Asia/Seoul" ))
2026-04-03 18:09:14 +09:00
var checkTime = 60_000 * 3L
2026-06-03 13:27:05 +09:00
val isTradingDay = MarketUtil . canTradeToday ()
2026-04-29 15:32:09 +09:00
if ( isTradingDay && now . isAfter ( KisSession . startTime ()) && now . isBefore ( KisSession . endTime ()) && ! shouldShowFullWindow ) {
2026-03-27 10:59:59 +09:00
shouldShowFullWindow = true
2026-06-09 14:16:18 +09:00
// SystemSleepPreventer.wakeDisplay()
} else if (( now . isAfter ( LocalTime . of ( 23 , 50 )) && now . isBefore ( LocalTime . of ( 8 , 0 )))) {
// SystemSleepPreventer.sleepDisplay()
2026-04-03 18:09:14 +09:00
}
2026-06-09 14:16:18 +09:00
// if (!isTradingDay) {
// checkTime = 60_000 * 30L
// }
2026-04-03 18:09:14 +09:00
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-08 15:33:07 +09:00
val seoulZone = ZoneId . of ( "Asia/Seoul" )
val now = LocalTime . now ( ZoneId . of ( "Asia/Seoul" ))
2026-04-29 15:32:09 +09:00
if ( KisSession . isAvailBuyTime ( now ) && isSuccess && completeTradingDecision != null ) {
2026-04-08 14:18:09 +09:00
val decision = completeTradingDecision
2026-03-13 16:37:53 +09:00
2026-06-01 13:37:07 +09:00
println ( " ${decision.stockName} ${decision.decision} " )
2026-04-08 14:18:09 +09:00
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
2026-06-01 13:37:07 +09:00
if ( decision . decision == "BUY" ) {
2026-06-19 14:31:33 +09:00
var maxRealisticProfitRate = 0.0
2026-04-08 14:18:09 +09:00
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
val grade = decision . investmentGrade ?: InvestmentGrade . LEVEL_1_SPECULATIVE
2026-03-13 16:37:53 +09:00
2026-06-19 14:31:33 +09:00
decision . analyzer ?. let { a ->
val volatility = a ?. calculateVolatilityForecast ( a . daily , 20 )
volatility ?. let {
maxRealisticProfitRate = (( volatility . realisticHigh - decision . currentPrice ) / decision . currentPrice ) * 100.0
}
}
// 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준)
// 2. 시스템 기본 설정 수익률과 비교
val baseProfitRate = KisSession . config . getValues ( ConfigIndex . PROFIT_INDEX ) + KisSession . config . getValues ( grade . profitGuide )
// 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤
val finalProfitRate = if ( maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate ) {
max ( maxRealisticProfitRate , 0.05 )
} else {
baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행
}
2026-04-08 14:18:09 +09:00
// 2. 최종 매수 실행
val gradeRate = KisSession . config . getValues ( grade . allocationRate )
val maxBudget = KisSession . config . getValues ( ConfigIndex . MAX_BUDGET_INDEX ) * gradeRate
2026-06-19 14:31:33 +09:00
decision . maxRealisticProfitRate = maxRealisticProfitRate
TradingLogStore . addLog ( decision , "BUY" , decision . summary ( KisSession . config . getValues ( ConfigIndex . PROFIT_INDEX ) * KisSession . config . getValues ( grade . profitGuide )))
2026-06-18 17:56:34 +09:00
var hasCodes = KisSession . tradeConfig . lowerAveragePrice && currentBalance ?. getHoldings () ?. any { it . code . equals ( decision . stockCode ) && it . quantity . toInt () > 2 && it . availOrderCount . toInt () > 0 } ?: false
2026-05-26 11:20:37 +09:00
if ( hasCodes == true ) {
TradingLogStore . addNotice ( decision . stockName , decision . stockCode , "물타기 시도 1주 매수" )
}
val calculatedQty = if ( hasCodes == true ) KisSession . tradeConfig . lowerAverageStockCount else ( maxBudget / decision . currentPrice ). toInt (). coerceAtLeast ( 1 )
2026-04-08 14:18:09 +09:00
excuteTrade (
decision = decision ,
orderQty = calculatedQty . toString (),
2026-06-19 14:31:33 +09:00
profitRate1 = finalProfitRate ,
2026-06-02 17:10:06 +09:00
investmentGrade = grade ,
hasCode = hasCodes == true
2026-04-08 14:18:09 +09:00
)
} else if ( decision . decision . equals ( "RETRY" ) || decision . confidence >= 60.0 ) { // 아까운 종목만 재분석
addToReanalysis ( RankingStock ( decision . stockCode , decision . stockName ))
}
2026-04-08 15:33:07 +09:00
} else {
2026-04-08 15:45:18 +09:00
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-03-13 16:37:53 +09:00
}
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-03-13 16:37:53 +09:00
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-06-01 13:37:07 +09:00
2026-06-02 17:10:06 +09:00
fun excuteTrade ( decision : TradingDecision , orderQty : String , profitRate1 : Double ?, investmentGrade : InvestmentGrade = InvestmentGrade . LEVEL_2_HIGH_RISK , hasCode : Boolean ) {
2026-03-13 16:37:53 +09:00
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 ())
2026-04-08 15:33:07 +09:00
val maxStocks = KisSession . config . getValues ( ConfigIndex . MAX_HOLDING_COUNT ). toInt ()
2026-06-02 14:43:13 +09:00
2026-04-08 15:33:07 +09:00
if (! canAddNewPosition ( maxStocks )) {
TradingLogStore . addNotice ( "SYSTEM" , "LIMIT" , "최대 보유 종목 도달로 신규 매수 일시 중단" )
2026-06-02 17:10:06 +09:00
addToReanalysis ( RankingStock ( mksc_shrn_iscd = stockCode , hts_kor_isnm = stockName ))
2026-05-13 11:37:57 +09:00
TradingLogStore . addWatchLog ( decision , "WATCH" , "매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가" )
2026-05-18 17:56:26 +09:00
} else if ( KisSession . isAvailBuyTime ( LocalTime . now ())){
2026-05-26 11:20:37 +09:00
println ( "basePrice : $basePrice , oneTickLowerPrice : $oneTickLowerPrice , finalPrice : $finalPrice hasStocks : ${stockCode.contains(stockCode)} " )
var realOrderQty = orderQty
KisTradeService . postOrder ( stockCode , realOrderQty , finalPrice . toLong (). toString (), isBuy = true )
2026-04-08 15:33:07 +09:00
. onSuccess { realOrderNo ->
println ( "[ ${investmentGrade.displayName} ] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice " )
2026-05-26 11:20:37 +09:00
TradingLogStore . addLog (
decision ,
"BUY" ,
"[ ${investmentGrade.displayName} ] 주문 성공: $realOrderNo "
)
2026-03-13 16:37:53 +09:00
2026-04-08 15:33:07 +09:00
val sRate = - 1.5
var tax = KisSession . config . getValues ( ConfigIndex . TAX_INDEX )
2026-05-26 11:20:37 +09:00
val effectiveProfitRate =
( profitRate1 ?: KisSession . config . getValues ( ConfigIndex . PROFIT_INDEX )) + tax
2026-06-04 10:42:54 +09:00
try {
var oldTarget = currentBalance ?. getHoldings () ?. first { it . availOrderCount . toInt () > 0 && it . code . equals ( decision . stockCode ) }
2026-06-18 17:56:34 +09:00
if ( KisSession . tradeConfig . lowerAveragePrice && hasCode && oldTarget != null ) {
2026-06-04 10:42:54 +09:00
var avgPrive = oldTarget . avgPrice . toDouble ()
var qty = oldTarget . quantity . toDouble ()
2026-06-12 13:11:23 +09:00
basePrice = avgPrive * 1.5 //((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt()))
2026-06-04 10:42:54 +09:00
println ( "물타기 ${avgPrive} , ${qty} ${basePrice} " )
}
} catch ( e : Exception ) { e . printStackTrace ()}
2026-03-13 16:37:53 +09:00
2026-05-26 11:20:37 +09:00
val calculatedTarget =
MarketUtil . roundToTickSize ( basePrice * ( 1 + effectiveProfitRate / 100.0 ))
2026-04-08 15:33:07 +09:00
val calculatedStop = MarketUtil . roundToTickSize ( basePrice * ( 1 + sRate / 100.0 ))
val inputQty = orderQty . replace ( "," , "" ). toIntOrNull () ?: 0
2026-04-08 14:18:09 +09:00
2026-05-26 11:20:37 +09:00
DatabaseFactory . saveAutoTrade (
AutoTradeItem (
orderNo = realOrderNo ,
code = stockCode ,
name = stockName ,
quantity = inputQty ,
profitRate = effectiveProfitRate ,
stopLossRate = sRate ,
targetPrice = calculatedTarget ,
stopLossPrice = calculatedStop ,
status = "PENDING_BUY" ,
isDomestic = true
)
)
2026-04-16 15:48:23 +09:00
TradingReportManager . recordTradeDecision (
orderNo = realOrderNo ,
stockCode = stockCode ,
stockName = stockName ,
isBuy = true ,
orderQty = inputQty ,
reason = decision . reason ?: "" , // AI 이유
decision = decision // AI 객체 통째로 전달
)
2026-06-19 14:31:33 +09:00
if (! hasCode ) {
syncAndExecute ( realOrderNo )
}
2026-04-08 15:33:07 +09:00
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
2026-05-26 11:20:37 +09:00
TradingLogStore . addLog (
decision ,
"BUY" ,
"[ ${investmentGrade.displayName} ] 매수 및 감시 설정 완료 (목표 수익률: ${
String . format (
"%.4f" ,
effectiveProfitRate
)
}%): $ realOrderNo "
)
2026-04-01 14:35:56 +09:00
}
2026-04-08 15:33:07 +09:00
. onFailure {
println ( "매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice " )
if ( it . message ?. contains ( "주문가능금액을 초과" ) == true ) {
2026-05-26 11:20:37 +09:00
AutoTradingManager . addToReanalysis (
RankingStock (
mksc_shrn_iscd = stockCode ,
hts_kor_isnm = stockName
)
)
TradingLogStore . addWatchLog (
decision ,
"WATCH" ,
" ${it.message ?: " 매수 실패"} => 재분석 대기열에 추가"
)
2026-04-08 15:33:07 +09:00
} else {
2026-05-26 11:20:37 +09:00
TradingLogStore . addLog ( decision , "BUY" , it . message ?: "매수 실패" )
2026-04-08 15:33:07 +09:00
}
}
2026-05-18 17:56:26 +09:00
} else {
val unfilledResult = KisTradeService . fetchUnfilledOrders ()
unfilledResult . onSuccess { response ->
response . filter { it . sll_buy_dvsn_cd == "02" }. forEach { order ->
TradingLogStore . addNotice ( order . prdt_name , order . pdno , "[주문 취소] 매수시간 종료 후 모든 매수 취소" )
KisTradeService . cancelOrder (
order . ord_no , // 원주문번호
order . pdno
)
}
}
2026-04-08 15:33:07 +09:00
}
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 )
2026-06-04 10:42:54 +09:00
println ( "exec >> ${exec} " )
2026-03-13 17:34:48 +09:00
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 )
2026-06-19 14:31:33 +09:00
2026-03-13 16:37:53 +09:00
try {
val dbItem = DatabaseFactory . findByOrderNo ( orderNo )
val execData = executionCache [ orderNo ]
if ( dbItem != null && execData != null && execData . isFilled ) {
if ( dbItem . status == TradeStatus . PENDING_BUY ) {
2026-04-17 18:08:53 +09:00
// ✅ 1. 진짜 사온 가격 (실제 매수 체결가)
2026-06-12 13:11:23 +09:00
var actualBuyPrice = execData . price . toDoubleOrNull () ?: dbItem . targetPrice
2026-03-13 16:37:53 +09:00
2026-04-17 18:08:53 +09:00
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
TradingReportManager . updateExecution ( orderNo , actualBuyPrice , dbItem . quantity )
2026-06-18 17:56:34 +09:00
var hasCodes = KisSession . tradeConfig . lowerAveragePrice && currentBalance ?. getHoldings () ?. any { it . code . equals ( dbItem . code ) && it . quantity . toInt () > 2 && dbItem . quantity == KisSession . tradeConfig . lowerAverageStockCount } ?: false
2026-06-12 13:11:23 +09:00
if ( hasCodes ) {
actualBuyPrice = actualBuyPrice * 1.1
}
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
val finalTargetPrice = MarketUtil . roundToTickSize ( actualBuyPrice * ( 1 + finalProfitRate / 100.0 ))
2026-04-17 18:08:53 +09:00
println ( "🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()} " )
2026-03-13 16:37:53 +09:00
KisTradeService . postOrder (
stockCode = dbItem . code ,
qty = dbItem . quantity . toString (),
price = finalTargetPrice . toLong (). toString (),
isBuy = false
). onSuccess { newSellOrderNo ->
2026-04-17 18:08:53 +09:00
// 💡 [매도 주문 기록] 이제 팔기 시작했다는 의사결정을 리포트에 남깁니다.
2026-04-16 15:48:23 +09:00
TradingReportManager . recordTradeDecision (
orderNo = newSellOrderNo ,
stockCode = dbItem . code ,
stockName = dbItem . name ,
isBuy = false ,
orderQty = dbItem . quantity ,
2026-04-17 18:08:53 +09:00
reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)} % 도달을 위한 익절 주문" ,
holdingAvgPrice = actualBuyPrice , // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다!
decision = null
2026-04-16 15:48:23 +09:00
)
2026-03-13 16:37:53 +09:00
DatabaseFactory . updateStatusAndOrderNo ( dbItem . id !! , TradeStatus . SELLING , newSellOrderNo )
executionCache . remove ( orderNo )
}
} else if ( dbItem . status == TradeStatus . SELLING ) {
2026-04-17 18:08:53 +09:00
// ✅ 2. 매도 완료 시점 (실제 매도 체결가)
val actualSellPrice = execData . price . toDoubleOrNull () ?: 0.0
val actualSellQty = execData . qty . toIntOrNull () ?: dbItem . quantity
// 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록
TradingReportManager . updateExecution ( orderNo , actualSellPrice , actualSellQty )
println ( "🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()} " )
2026-05-20 17:18:15 +09:00
TradingLogStore . addSellLog ( dbItem . name , actualSellPrice . toString (), "SELL" , "매도 완료" )
2026-05-26 14:18:41 +09:00
2026-04-17 18:08:53 +09:00
TradingReportManager . closePositionCycle ( dbItem . code ) // 사이클 종료 알림
2026-04-16 15:48:23 +09:00
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-06-04 11:37:08 +09:00
fun startAutoDiscoveryLoop ( doStart : Boolean = false ) {
2026-02-06 17:53:17 +09:00
if ( isRunning ()) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
watchdogJob ?. cancel ()
watchdogJob = scope . launch {
2026-06-02 15:30:17 +09:00
val activeTrades = DatabaseFactory . findAllMonitoringTrades ()
var now = LocalTime . now ( ZoneId . of ( "Asia/Seoul" ))
2026-06-04 11:37:08 +09:00
if ( doStart && activeTrades . isNotEmpty () && ! KisSession . isAvailBuyTime ( now )) {
2026-06-02 15:30:17 +09:00
executeClosingLiquidation ( activeTrades )
}
2026-02-06 17:53:17 +09:00
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-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-04-03 17:34:27 +09:00
suspend fun sellingAfterMarketOnePrice ( tradeService : KisTradeService , balance : UnifiedBalance , marketCode : String = "Y" ) {
2026-04-17 18:08:53 +09:00
balance . getHoldings (). 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-02-19 15:47:31 +09:00
} else {
2026-06-19 18:08:15 +09:00
val now = LocalTime . now ()
2026-06-26 10:17:03 +09:00
val targetProfitLimit = if ( holding . isTodayEntry && now . isBefore ( LocalTime . of ( 16 , 0 ))) {
2026-04-09 17:03:52 +09:00
// 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도)
KisSession . config . getValues ( ConfigIndex . PROFIT_INDEX )+ KisSession . config . getValues ( ConfigIndex . TAX_INDEX )
} else {
// 오래 보유한 종목: 기존 설정값 준수 (예: 3.0% 등)
KisSession . config . SELL_PROFIT
}
if ( holding != null && holding . quantity . toInt () > 0 && holding . availOrderCount . toInt () > 0 && holding . profitRate . toDouble () > targetProfitLimit ) {
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
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 ->
2026-04-09 17:03:52 +09:00
println ( "✅ [ ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주문 완료] ${holding.name} : $newOrderNo " )
2026-04-07 17:32:21 +09:00
TradingLogStore . addSellLog (
2026-04-08 17:25:04 +09:00
" ${holding.name} [ ${holding.code} ]" ,
2026-04-07 17:32:21 +09:00
targetPrice . toString (),
"SELL" ,
2026-04-08 15:45:18 +09:00
"🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료"
2026-04-07 17:32:21 +09:00
)
2026-04-16 15:48:23 +09:00
DatabaseFactory . saveAutoTrade ( AutoTradeItem (
orderNo = newOrderNo ,
code = holding . code ,
name = holding . name ,
quantity = holding . quantity . toInt (),
profitRate = 0.0 ,
stopLossRate = 0.0 ,
targetPrice = targetPrice . toDouble (),
stopLossPrice = 0.0 ,
status = "SELLING" ,
isDomestic = true
))
syncAndExecute ( newOrderNo )
2026-04-07 17:32:21 +09:00
}. onFailure {
TradingLogStore . addSellLog (
2026-04-08 17:25:04 +09:00
" ${holding.name} [ ${holding.code} ]" ,
2026-04-07 17:32:21 +09:00
targetPrice . toString (),
"SELL" ,
2026-04-08 15:45:18 +09:00
"🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 실패[ ${it.message} ] "
2026-04-07 17:32:21 +09:00
)
}
2026-04-08 14:18:09 +09:00
} else {
2026-04-13 18:02:38 +09:00
if ( "Y" . equals ( marketCode )) {
2026-04-17 18:08:53 +09:00
if ( KisSession . config . getValues ( ConfigIndex . STOP_LOSS ) > 0.0
&& holding != null && holding . quantity . toInt () > 0
&& holding . availOrderCount . toInt () > 0
&& holding . profitRate . toDouble () <= KisSession . config . getValues ( ConfigIndex . LOSS_MINRATE )
&& holding . profitRate . toDouble () >= KisSession . config . getValues ( ConfigIndex . LOSS_MAXRATE )
&& holding . valuationProfitAmount . toDouble () >= KisSession . config . getValues ( ConfigIndex . LOSS_MAX_MONEY )) {
println ( " ${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)} " )
val profit = holding . profitRate . toDouble ()
TradingLogStore . addNotice (
"보유주식[ ${holding.name} ]" ,
holding . code ,
"수익률( $profit %) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함."
)
}
2026-04-13 18:02:38 +09:00
analyzeDeepLossHoldingsAfterMarket ( holding )
}
2026-03-27 17:54:21 +09:00
}
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-08 15:45:18 +09:00
if ( now . isBefore ( H15M30 ) && now . isAfter ( H08M45 )) {
println ( "resumePendingSellOrders" )
2026-04-17 18:08:53 +09:00
balance . getHoldings (). forEach { holding ->
2026-04-08 15:45:18 +09:00
if ( BLACKLISTEDSTOCKCODES . contains ( holding . code )){
println ( "❌ 차단 처리된 주식 : ${holding.name} " )
TradingLogStore . addAnalyzer (
holding . name ,
holding . code ,
"거랙 차단 대상 : ${holding.currentPrice} [ ${holding.quantity} 주] 보유, 수익률( ${holding.profitRate.toDouble()} )"
)
2026-04-08 14:18:09 +09:00
} else {
2026-04-08 15:45:18 +09:00
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
} else {
targetPrice = MarketUtil . roundToTickSize ( targetPrice + MarketUtil . getTickSize ( targetPrice ))
}
println ( "🔄 [보유 주식 주문] ${holding.name} ( ${holding.code} ) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도" )
tradeService . postOrder (
stockCode = holding . code ,
qty = holding . availOrderCount ,
price = targetPrice . toInt (). toString (),
isBuy = false ,
). onSuccess { newOrderNo ->
println ( "✅ [보유 주식 주문 완료] ${holding.name} : $newOrderNo " )
TradingLogStore . addSellLog (
holding . code ,
targetPrice . toString (),
"SELL" ,
"🎊 보유 주식[예상수익 : ${holding.profitRate} ] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice} }로 매도[ $targetPrice ] 주문" else "09:30 이후 시세{ ${holding.currentPrice} } 기준 호가 위 매도[ $targetPrice ] 주문" } 완료 "
)
2026-04-17 18:08:53 +09:00
DatabaseFactory . saveAutoTrade ( AutoTradeItem (
orderNo = newOrderNo ,
code = holding . code ,
name = holding . name ,
quantity = holding . quantity . toInt (),
profitRate = 0.0 ,
stopLossRate = 0.0 ,
targetPrice = targetPrice . toDouble (),
stopLossPrice = 0.0 ,
status = "SELLING" ,
isDomestic = true
))
syncAndExecute ( newOrderNo )
2026-04-08 15:45:18 +09:00
}. onFailure {
TradingLogStore . addSellLog (
holding . code ,
targetPrice . toString (),
"SELL" ,
"🎊 보유 주식 매도 주문 실패[ ${it.message} ] "
)
}
} else {
2026-05-20 17:18:15 +09:00
var errMsg = ""
var isSuccess = false
2026-05-26 11:20:37 +09:00
if ( KisSession . tradeConfig . autoSellOrder
2026-04-17 18:08:53 +09:00
&& holding != null && holding . quantity . toInt () > 0
&& holding . availOrderCount . toInt () > 0
2026-06-02 11:19:31 +09:00
&& holding . profitRate . toDouble () <= KisSession . tradeConfig . autoSellOrderMin
&& holding . profitRate . toDouble () >= KisSession . tradeConfig . autoSellOrderMax
2026-05-19 13:33:59 +09:00
&& holding . avgPrice . toDouble () > holding . currentPrice . toDouble ()) {
var targetPrice = holding . avgPrice . toDouble ()
2026-06-02 11:19:31 +09:00
targetPrice = MarketUtil . roundToTickSize ( targetPrice + MarketUtil . getTickSize ( targetPrice ) * KisSession . tradeConfig . autoSellOrderAppend )
2026-04-29 15:32:09 +09:00
tradeService . postOrder (
stockCode = holding . code ,
qty = holding . availOrderCount ,
2026-05-07 14:53:19 +09:00
price = targetPrice . toInt (). toString (),
2026-04-29 15:32:09 +09:00
isBuy = false ,
). onSuccess { newOrderNo ->
2026-05-20 17:18:15 +09:00
println ( "✅ [보유 주식 손절 처리] ${holding.name} 매수가 기준 ( ${holding.avgPrice.toDouble()} 3호가 위[ ${targetPrice} ] 매도 주문" )
isSuccess = true
2026-05-07 14:53:19 +09:00
}. onFailure { err ->
2026-05-20 17:18:15 +09:00
println ( "✅ [보유 주식 손절 처리] ${holding.name} 실패 ${targetPrice} ${err.message} " )
errMsg = err . message . toString ()
2026-04-29 15:32:09 +09:00
}
2026-05-07 14:53:19 +09:00
2026-04-17 18:08:53 +09:00
TradingLogStore . addNotice (
"보유주식[ ${holding.name} ]" ,
holding . code ,
2026-05-20 17:18:15 +09:00
"매수가 기준 ( ${holding.avgPrice.toDouble()} 3호가 위[ ${targetPrice} ] 매도 주문 ${if (isSuccess) "성공" else "실패[${errMsg} ]" } "
2026-04-17 18:08:53 +09:00
)
2026-05-19 13:33:59 +09:00
} else if ( KisSession . config . stop_Loss
2026-05-26 11:20:37 +09:00
&& holding != null && holding . quantity . toInt () > 0
&& holding . availOrderCount . toInt () > 0
&& holding . profitRate . toDouble () <= KisSession . config . getValues ( ConfigIndex . LOSS_MINRATE )
&& holding . profitRate . toDouble () >= KisSession . config . getValues ( ConfigIndex . LOSS_MAXRATE )
&& holding . valuationProfitAmount . toDouble () >= KisSession . config . getValues ( ConfigIndex . LOSS_MAX_MONEY )) {
println ( " ${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)} " )
val profit = holding . profitRate . toDouble ()
var targetPrice = holding . currentPrice . toDouble ()
2026-06-08 14:45:15 +09:00
targetPrice = MarketUtil . roundToTickSize ( targetPrice + MarketUtil . getTickSize ( targetPrice ) * KisSession . tradeConfig . autoSellOrderAppend )
2026-05-19 13:33:59 +09:00
2026-05-26 11:20:37 +09:00
tradeService . postOrder (
stockCode = holding . code ,
qty = holding . availOrderCount ,
price = targetPrice . toInt (). toString (),
isBuy = false ,
). onSuccess { newOrderNo ->
println ( "✅ [보유 주식 손절 처리] 수익률( $profit %) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도." )
}. onFailure { err ->
println ( "✅ [보유 주식 손절 처리] 실패 ${err.message} " )
}
2026-05-19 13:33:59 +09:00
2026-05-26 11:20:37 +09:00
TradingLogStore . addNotice (
"보유주식[ ${holding.name} ]" ,
holding . code ,
"수익률( $profit %) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
)
}
2026-04-17 18:08:53 +09:00
analyzeDeepLossHoldingsAfterMarket ( holding , true )
2026-04-08 15:45:18 +09:00
}
delay ( 200 ) // API 호출 부하 방지
2026-04-03 17:02:42 +09:00
}
2026-02-19 15:47:31 +09:00
}
2026-04-08 14:18:09 +09:00
}
2026-02-19 15:47:31 +09:00
}
2026-04-08 14:18:09 +09:00
2026-04-17 18:08:53 +09:00
private suspend fun analyzeDeepLossHoldingsAfterMarket ( holding : UnifiedStockHolding , isForce : Boolean = false ) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
2026-04-08 14:18:09 +09:00
val now = LocalTime . now ()
val currentMinute = now . minute
2026-05-13 11:37:57 +09:00
if (( holding . availOrderCount . toInt ()
?: 0 ) > 0 && (( !is Force && ( now . hour == 8 || now . hour == 16 || now . hour == 17 )) || ( isForce && ( currentMinute % 5 == 0 )))
) {
2026-04-08 14:18:09 +09:00
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)} ). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
2026-04-17 18:08:53 +09:00
TradingLogStore . addNotice (
2026-04-08 16:12:02 +09:00
"보유주식[ ${holding.name} ]" ,
holding . code ,
2026-04-17 18:08:53 +09:00
"수익률( $profit %) -> $advice "
2026-04-08 16:12:02 +09:00
)
2026-04-08 14:18:09 +09:00
}
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
else if ( isTrendBroken && currentPrice > lowerBand * 1.1 ) {
advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다."
2026-04-08 16:12:02 +09:00
TradingLogStore . addNotice (
"보유주식[ ${holding.name} ]" ,
holding . code ,
"수익률 심각( $profit %) -> $advice " ,
2026-04-29 15:32:09 +09:00
holding . quantity . toInt ()
2026-04-08 16:12:02 +09:00
)
2026-04-08 14:18:09 +09:00
}
// 🟡 [관망] 어정쩡하게 물려있는 상태
else {
advice = "⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다."
2026-04-08 16:12:02 +09:00
TradingLogStore . addAnalyzer (
"보유주식[ ${holding.name} ]" ,
holding . code ,
"수익률( $profit %) -> $advice " , false
)
2026-04-08 14:18:09 +09:00
}
}
} 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-04-08 15:33:07 +09:00
fun canAddNewPosition ( // 대표님의 시스템에 맞는 미체결 주문 객체 리스트
maxAllowedStocks : Int
): Boolean {
// 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환
2026-05-29 13:29:17 +09:00
return true //(currentBalance?.getHoldings()?.count { it.availOrderCount.toInt() > 0 } ?: 0) > maxAllowedStocks
2026-04-08 15:33:07 +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-08 15:33:07 +09:00
val H15M30 = LocalTime . of ( 15 , 30 )
2026-04-08 08:25:49 +09:00
val H08M45 = LocalTime . of ( 8 , 45 )
2026-04-29 15:32:09 +09:00
2026-03-26 13:48:26 +09:00
private fun runDiscoveryLoop ( callback : TradingDecisionCallback ) {
2026-02-06 17:53:17 +09:00
discoveryJob = scope . launch {
println ( "🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()} " )
while ( isActive ) {
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-29 15:32:09 +09:00
now . isAfter ( KisSession . endTime ()) || now . isBefore ( KisSession . startTime ()) -> {
2026-03-26 13:48:26 +09:00
prepareMarketOpen ( now )
}
2026-04-29 15:32:09 +09:00
now . isBefore ( KisSession . endTime ()) && now . isAfter ( KisSession . startTime ()) -> {
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 ()
}
2026-02-20 15:21:38 +09:00
}
}
2026-03-13 10:41:10 +09:00
withTimeout ( CYCLE_TIMEOUT ) {
println ( "⏱️ [Cycle Start] ${LocalTime.now()} " )
2026-06-02 14:40:33 +09:00
val activeTrades = DatabaseFactory . findAllMonitoringTrades ()
2026-06-02 15:30:17 +09:00
if ( now . isAfter ( KisSession . endBuyTime ()) && activeTrades . isNotEmpty ()) {
2026-06-02 14:40:33 +09:00
executeClosingLiquidation ( activeTrades )
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-06 17:53:17 +09:00
}
2026-03-13 10:41:10 +09:00
else -> {
waitTime = 3.0
}
2026-02-06 17:53:17 +09:00
}
} catch ( e : TimeoutCancellationException ) {
println ( "⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다." )
} catch ( e : Exception ) {
println ( "⚠️ [Loop Error] ${e.message} " )
2026-05-04 11:19:52 +09:00
delay ( 1000 )
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-03-26 13:48:26 +09:00
suspend fun prepareMarketOpen ( now : LocalTime ) {
2026-04-29 15:32:09 +09:00
if ( now . isAfter ( KisSession . endTime ()) || now . isBefore ( KisSession . startTime ())) {
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-06-12 13:11:23 +09:00
} else if ( now . isAfter ( KisSession . startTime (). minusMinutes ( 20 )) && now . isBefore ( KisSession . startTime ()) && ! shouldShowFullWindow ) {
2026-03-26 13:48:26 +09:00
if ( MarketUtil . canTradeToday ()) {
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 >>()
2026-06-02 17:10:06 +09:00
var defaultStockCount = 30
2026-03-26 13:48:26 +09:00
2026-04-08 15:33:07 +09:00
var currentBalance : UnifiedBalance ? = null
2026-06-04 10:51:24 +09:00
private var lastFetchTime : Long = 0L // 마지막 성공 시간 (Millisecond)
2026-06-09 14:16:18 +09:00
private val FETCH _INTERVAL = 2 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms)
2026-05-26 14:18:41 +09:00
2026-06-04 10:51:24 +09:00
suspend fun checkBalance () {
val currentTime = System . currentTimeMillis ()
if ( currentBalance == null || ( currentTime - lastFetchTime ) > FETCH_INTERVAL ) {
2026-06-02 10:43:37 +09:00
KisTradeService . fetchIntegratedBalance (). getOrNull () ?. let {
currentBalance = it
2026-06-04 10:51:24 +09:00
lastFetchTime = currentTime // 호출 성공 시 현재 시간으로 갱신
println ( "잔고 동기화 완료" )
} ?: run {
println ( "잔고 조회 실패 (네트워크 오류 등)" )
2026-06-02 10:43:37 +09:00
}
2026-06-04 10:51:24 +09:00
2026-04-06 09:55:13 +09:00
} else {
2026-06-04 10:51:24 +09:00
// 30분이 지나지 않았다면 기존에 저장된 currentBalance를 그대로 사용
2026-06-09 14:16:18 +09:00
println ( " ${(FETCH_INTERVAL / (1000 * 60))} 분이 지나지 않아 기존 잔고 데이터 유지 (남은 시간: ${(FETCH_INTERVAL - (currentTime - lastFetchTime)) / 1000} 초)" )
2026-04-06 09:55:13 +09:00
}
2026-06-04 10:51:24 +09:00
if ( KisSession . config . take_profit ) currentBalance ?. let { resumePendingSellOrders ( KisTradeService , it ) }
if ( KisSession . tradeConfig . auto_cancel_pending_buy ) { checkAndCancelPendingBuyOrders () }
2026-04-02 14:05:14 +09:00
}
2026-03-27 17:54:21 +09:00
2026-04-29 15:32:09 +09:00
suspend fun checkAndCancelPendingBuyOrders (
) {
// 1. 미체결 내역 조회
val unfilledResult = KisTradeService . fetchUnfilledOrders ()
unfilledResult . onSuccess { response ->
val currentTime = System . currentTimeMillis ()
// 매수 주문('02')만 필터링
response . filter { it . sll_buy_dvsn_cd == "02" }. forEach { order ->
val orderTimeMillis = parseOrderTime ( order . ord_tmd )
val elapsedMillis = currentTime - orderTimeMillis
2026-05-18 17:56:26 +09:00
if ( elapsedMillis >= KisSession . tradeConfig . auto_cancel_pending_time ) {
2026-04-29 15:32:09 +09:00
// 2. 현재가 조회 (가격을 비교하기 위해)
val currentPrice = KisTradeService . fetchCurrentPrice ( order . pdno ). getOrNull () ?. stck_prpr ?. toDouble () ?: 0.0
val orderedPrice = order . ord_unpr . toDoubleOrNull () ?: 0.0
// 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때)
val priceGap = Math . abs ( currentPrice - orderedPrice ) / orderedPrice
println ( "checkAndCancelPendingBuyOrders order $order ${elapsedMillis / 1000L} 초 ${priceGap} % 차이" )
if ( priceGap >= KisSession . tradeConfig . auto_cancel_pending_rate ) {
2026-05-13 11:37:57 +09:00
TradingLogStore . addNotice ( order . prdt_name , order . pdno , "[주문 취소] ${order.prdt_name} ( ${order.pdno} ) - 시간경과 및 가격괴리( ${String.format("%.2f", priceGap)} %)로 취소 시도" )
2026-04-29 15:32:09 +09:00
KisTradeService . cancelOrder (
order . ord_no , // 원주문번호
order . pdno
)
}
}
}
}
}
2026-05-14 13:27:33 +09:00
suspend fun cancelAllPendingSellOrders (
) {
// 1. 미체결 내역 조회
val unfilledResult = KisTradeService . fetchUnfilledOrders ()
unfilledResult . onSuccess { response ->
response . filter { it . sll_buy_dvsn_cd == "01" }. forEach { order ->
TradingLogStore . addNotice ( order . prdt_name , order . pdno , "[주문 취소] 정규장 시작전 모든 매도 주문 취소" )
KisTradeService . cancelOrder (
order . ord_no , // 원주문번호
order . pdno
)
}
}
}
2026-04-29 15:32:09 +09:00
// 주문 시간 문자열을 Millis로 변환하는 유틸리티 (당일 주문 기준)
fun parseOrderTime ( ordTmd : String ): Long {
return try {
val now = java . time . LocalDateTime . now ()
val hour = ordTmd . substring ( 0 , 2 ). toInt ()
val min = ordTmd . substring ( 2 , 4 ). toInt ()
val sec = ordTmd . substring ( 4 , 6 ). toInt ()
val orderDateTime = now . withHour ( hour ). withMinute ( min ). withSecond ( sec )
orderDateTime . atZone ( java . time . ZoneId . systemDefault ()). toInstant (). toEpochMilli ()
} catch ( e : Exception ) {
System . currentTimeMillis ()
}
}
2026-04-02 14:05:14 +09:00
suspend fun executeMarketLoop () {
2026-04-08 15:33:07 +09:00
checkBalance ()
2026-06-16 13:07:38 +09:00
var myCash = currentBalance ?. deposit ?. replace ( "," , "" ) ?. toLongOrNull () ?: KisSession . config . getValues ( ConfigIndex . MAX_PRICE_INDEX ). toLong ()
myCash = max ( myCash , KisSession . config . getValues ( ConfigIndex . MAX_PRICE_INDEX ). toLong ())
2026-05-26 14:18:41 +09:00
val myHoldings = currentBalance ?. getHoldings () ?. filter { ! it . isTodayEntry } ?. map { it . code } ?. toSet () ?: emptySet ()
val pendingStocks = DatabaseFactory . findAllMonitoringTrades (). map { it . code }
2026-06-02 17:10:06 +09:00
var now = LocalTime . now ( ZoneId . of ( "Asia/Seoul" ))
2026-03-26 13:48:26 +09:00
if ( remainingCandidates . isEmpty ()) {
2026-06-01 17:55:10 +09:00
if ( loadedTops . size < defaultStockCount ) {
2026-03-26 13:48:26 +09:00
loadedTops . addAll ( StockUniverseLoader . loadUniverse ())
println ( "✅ 총 ${loadedTops.size} 개의 종목이 로드되있음." )
}
2026-06-19 14:40:46 +09:00
loadedTops . shuffle ()
val count = minOf ( loadedTops . size , defaultStockCount )
for ( i in 0 .. < count ) {
loadedTops . removeFirst (). let {
addToReanalysis ( RankingStock ( mksc_shrn_iscd = it . first , hts_kor_isnm = it . second ))
}
2026-03-26 13:48:26 +09:00
}
val candidates : MutableList < RankingStock > = fetchCandidates ( KisTradeService ). apply {
}. filter {
val rate = it . prdy_ctrt . toDouble ()
val corpInfo = DartCodeManager . getCorpCode ( it . code )
2026-07-10 14:55:19 +09:00
val isOk = ( rate > 0 && rate < KisSession . tradeConfig . plusFilter ) || ( rate < 0 && rate > ( abs ( KisSession . tradeConfig . minusFilter ) * - 1 ))
2026-06-08 14:45:15 +09:00
2026-03-26 13:48:26 +09:00
if ( corpInfo ?. cName . isNullOrEmpty ()) {
false
2026-06-08 14:45:15 +09:00
} else if ( it . code !in myHoldings &&
2026-06-26 10:17:03 +09:00
it . code !in pendingStocks &&
it . code !in executionCache . values . map { it . code } &&
it . code !in failList &&
it . code !in isSafetyBeltStockCodes ){
2026-03-26 13:48:26 +09:00
isOk
2026-06-08 14:45:15 +09:00
} else {
false
2026-03-26 13:48:26 +09:00
}
}
. filter { ! it . name . contains ( "호스팩" , true ) }
. toMutableList ()
if ( reanalysisList . isNotEmpty ()) {
candidates . addAll ( reanalysisList )
}
reanalysisList . clear ()
2026-05-26 11:20:37 +09:00
if ( KisSession . tradeConfig . lowerAveragePrice ) {
currentBalance ?. getHoldings () ?. map {
2026-06-01 17:55:10 +09:00
if (
2026-05-29 13:29:17 +09:00
it . quantity . toInt () > KisSession . tradeConfig . lowerAverageTargetCount &&
2026-06-08 14:45:15 +09:00
it . profitRate . toDouble () < ( abs ( KisSession . tradeConfig . lowerAverageMaxRate ) * - 1 ) &&
it . profitRate . toDouble () > ( abs ( KisSession . tradeConfig . lowerAverageMinRate ) * - 1 ))
2026-06-01 17:55:10 +09:00
{
2026-06-02 15:05:02 +09:00
candidates . add ( RankingStock ( mksc_shrn_iscd = it . code , hts_kor_isnm = it . name ))
println ( "물타기 대상 추가 ${it.name} [ ${it.code} ]" )
2026-06-02 17:10:06 +09:00
var oldTarget = it
if ( oldTarget != null ) {
var avgPrive = oldTarget . avgPrice . toDouble ()
var qty = oldTarget . quantity . toDouble ()
var basePrice = (( avgPrive * qty ) + it . currentPrice . toDouble ()). div ( qty !! . toInt () + 1 )
println ( "물타기 ${avgPrive} , ${qty} ${basePrice} " )
}
2026-05-26 11:20:37 +09:00
}
}
}
remainingCandidates . addAll ( candidates . filter {
2026-06-01 17:55:10 +09:00
( if ( KisSession . tradeConfig . lowerAveragePrice ) { true } else { it . code !in myHoldings }) &&
2026-05-26 11:20:37 +09:00
it . code !in pendingStocks &&
it . code !in executionCache . values . map { it . code } &&
it . code !in failList &&
it . code !in isSafetyBeltStockCodes
2026-06-19 14:40:46 +09:00
}. distinctBy { it . code })
remainingCandidates . shuffle ()
2026-03-26 13:48:26 +09:00
} else {
println ( "미확인 데이터 ${remainingCandidates.size} " )
}
var totalCount = remainingCandidates . size
println ( "후보군 조건 충족 총 개수 : ${totalCount} " )
val iterator = remainingCandidates . iterator ()
while ( iterator . hasNext ()) {
totalCount --
val stock = iterator . next ()
2026-04-29 15:32:09 +09:00
if ( KisSession . isAvailBuyTime ( now )) {
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} " )
2026-06-02 14:40:33 +09:00
delay ( 500 )
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-05-18 17:56:26 +09:00
// private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장
private val executionCountMap = mutableMapOf < String , Int >()
2026-04-02 14:05:14 +09:00
suspend fun sellSchedule () {
2026-05-18 17:56:26 +09:00
if ( KisSession . config . take_profit == false ) return
val now = LocalTime . now ()
val timeKey = String . format ( "%02d:%02d" , now . hour , now . minute ) // 예: "09:05"
val currentCount = executionCountMap . getOrDefault ( timeKey , 0 )
if ( currentCount >= KisSession . tradeConfig . excuteCountOnMin ) { return }
2026-04-02 14:05:14 +09:00
2026-05-18 17:56:26 +09:00
var isExecuted = false
val currentMinute = now . minute
if ( now . isBefore ( LocalTime . of ( 8 , 50 )) && now . isAfter ( LocalTime . of ( 8 , 45 ))) {
cancelAllPendingSellOrders ()
isExecuted = true
2026-06-26 10:17:03 +09:00
} else if ( ( now . isBefore ( LocalTime . of ( 15 , 40 )) && now . isAfter ( KisSession . endBuyTime ())) ) {
2026-05-18 17:56:26 +09:00
val unfilledResult = KisTradeService . fetchUnfilledOrders ()
unfilledResult . onSuccess { response ->
response . filter { it . sll_buy_dvsn_cd == "02" }. forEach { order ->
TradingLogStore . addNotice ( order . prdt_name , order . pdno , "[주문 취소] 정규장 후 모든 매수 취소" )
KisTradeService . cancelOrder (
order . ord_no , // 원주문번호
order . pdno
2026-04-20 17:09:54 +09:00
)
2026-04-06 15:07:14 +09:00
}
}
2026-05-18 17:56:26 +09:00
isExecuted = true
2026-05-20 17:18:15 +09:00
} else if ( now . hour == 9 && now . minute % KisSession . tradeConfig . excuteMinCheck == 0 ) {
2026-05-18 17:56:26 +09:00
TradingLogStore . addAnalyzer (
" - " ,
" - " ,
"⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute} 분 - 보유주식 매도 체크를 시작합니다." ,
true
)
println ( "⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute} 분 - 보유주식 매도 체크를 시작합니다." )
checkBalance ()
isExecuted = true
2026-06-26 10:17:03 +09:00
} else if (
(
( now . hour == 8 && KisSession . tradeConfig . before_nxt && currentMinute < 45 ) ||
( now . isAfter ( LocalTime . of ( 15 , 40 )) && now . isBefore ( LocalTime . of ( 20 , 0 )) && KisSession . tradeConfig . after_nxt )
) && ( currentMinute % 2 == 0 )) {
2026-05-18 17:56:26 +09:00
TradingLogStore . addAnalyzer (
" - " ,
" - " ,
"⏰ [강제 스케줄 실행] 오후 ${now.hour} 시 ${currentMinute} 분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다." ,
true
)
var list = mutableListOf < String >( "X" )
if ( now . hour != 8 && now . hour < 18 ) {
list . add ( "Y" )
}
list . forEach { code ->
KisTradeService . fetchIntegratedBalance ( code ). getOrNull () ?. let {
sellingAfterMarketOnePrice ( KisTradeService , it , code )
}
}
isExecuted = true
2026-04-03 17:02:42 +09:00
}
2026-05-18 17:56:26 +09:00
if ( isExecuted ) { executionCountMap [ timeKey ] = currentCount + 1 }
2026-05-19 13:33:59 +09:00
if ( now . hour >= 20 ) {
executionCountMap . clear ()
noticeFilter . clear ()
}
2026-04-02 14:05:14 +09:00
}
2026-03-26 13:48:26 +09:00
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-06-02 15:30:17 +09:00
if ( currentBalance ?. getHoldings () ?. any { it . code . equals ( stock . code ) && it . quantity . toInt () > 2 } == true ) {
println ( "물타기 대상 분석" )
}
2026-02-06 17:53:17 +09:00
callback ( TradingDecision (). apply {
this . stockCode = stock . code
this . confidence = - 1.0
this . stockName = stock . name
}, false )
2026-05-26 11:20:37 +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-12 15:31:34 +09:00
val currentPrice = today . stck_prpr . toDouble ()
2026-06-02 10:43:37 +09:00
if (( myCash > 10L && currentPrice > myCash ) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice ) {
2026-06-02 14:40:33 +09:00
print ( "-> [ ${stock.name} ] 가격 정책으로 제외 [1주: ${currentPrice} , 자산: ${myCash} , 최소 기준: ${minPrice} , 최대 기준: ${maxPrice} ] | " )
2026-02-12 15:31:34 +09:00
return @withTimeout
}
2026-06-19 14:31:33 +09:00
// 🌟 [추가] 고도화된 사전 필터링 (검문소)
val tempAnalyzer = TechnicalAnalyzer (). apply { this . daily = dailyData }
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?)
2026-08-03 11:53:29 +09:00
println ( "(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.8).toInt()} " )
val volatility = tempAnalyzer . calculateVolatilityForecast ( dailyData , ( dailyData . size * 0.8 ). toInt ())
2026-06-19 14:31:33 +09:00
val expectedProfitRate = (( volatility . realisticHigh - currentPrice ) / currentPrice ) * 100.0
// 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?)
2026-06-26 10:17:03 +09:00
val dailyStats = tempAnalyzer . calculateDynamicReboundStats ( dailyData , 5.0 )
2026-06-19 14:31:33 +09:00
val isApproaching = tempAnalyzer . checkReboundApproaching (
candles = dailyData ,
avgReboundTerm = dailyStats . avgReboundPeriod ,
2026-06-26 10:17:03 +09:00
dropThreshold = dailyStats . avgDropRate ,
2026-06-19 14:31:33 +09:00
timeTolerance = dailyStats . timeTolerance
)
2026-06-26 10:17:03 +09:00
print ( "-> [ ${stock.name} ] 필터링 ${dailyStats.avgReboundPeriod} ${dailyStats.avgDropRate} ${dailyStats.timeTolerance} " )
2026-08-03 11:53:29 +09:00
val isSteadyUptrend = false //tempAnalyzer.checkSteadyUptrend(dailyData)
2026-06-19 14:31:33 +09:00
2026-06-19 15:17:20 +09:00
2026-06-19 14:31:33 +09:00
// 🌟 [수정] 조건 통합 (OR 조건)
2026-06-19 15:17:20 +09:00
val isProfitable = expectedProfitRate >= KisSession . tradeConfig . minExpectedProfitRate || dailyStats . avgReboundAmplitude >= KisSession . tradeConfig . minExpectedProfitRate
2026-06-19 14:31:33 +09:00
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
2026-06-19 15:17:20 +09:00
val isValidEntryTiming = ( dailyStats . isValid && isApproaching && dailyStats . avgReboundPeriod <= KisSession . tradeConfig . maxExpectedReboundDays && dailyStats . avgReboundPeriod >= KisSession . tradeConfig . minExpectedReboundDays ) || isSteadyUptrend
2026-06-19 14:31:33 +09:00
if ( !is Profitable || !is ValidEntryTiming ) {
2026-06-19 15:17:20 +09:00
print ( "-> [ ${stock.name} ] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)} %, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)} 일, 진입권: $isValidEntryTiming ) | " )
2026-06-19 14:31:33 +09:00
return @withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
}
2026-06-26 10:17:03 +09:00
val dropPrediction = tempAnalyzer . predictDropBottom ( dailyData , dailyStats , volatility )
2026-06-19 14:31:33 +09:00
2026-06-26 10:17:03 +09:00
if ( dropPrediction != null ) {
// 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지!
// 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%)
2026-08-03 11:53:29 +09:00
if ( dropPrediction . remainingDropRate < - 1.5 && ! dropPrediction . isBottomZone ) {
2026-06-26 10:17:03 +09:00
print ( "-> [ ${stock.name} ] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)} % 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)} % 추가 하락 위험) | " )
return @withTimeout // 매수 후보에서 과감히 제외!
}
// 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김
}
2026-08-03 11:53:29 +09:00
if ( KisSession . tradeConfig . isUpcomingDividend ) {
var dividend = KisTradeService . fetchUpcomingDividend ( stock . code ). getOrNull ()
if ( dividend ?. hasDividend == true ){
println ( "[ ${stock.name} ] 배당락일 ${dividend.exDividendDate} : ${dividend.dividendAmount} " )
} else {
println ( "[ ${stock.name} ] 배당 정보 없어서 분석 종료" )
return @withTimeout
}
} else {
println ( "[ ${stock.name} ] 배당 정보 무관 함." )
}
2026-06-19 15:17:20 +09:00
println ( "🔍 [분석 진입] ${stock.name} ( ${LocalTime.now()} ) (예측수익: ${"%.1f".format(expectedProfitRate)} %, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)} 일, 진입권: $isValidEntryTiming )" )
2026-05-26 11:20:37 +09:00
if ( !is SafetyBeltStockCodes . contains ( stock . code )) {
val analyzer = coroutineScope {
val min30 = async { tradeService . fetchChartData ( stock . code , true ). getOrDefault ( emptyList ()) }
delay ( 20 )
val weekly =
async { tradeService . fetchPeriodChartData ( stock . code , "W" , true ). getOrDefault ( emptyList ()) }
delay ( 20 )
val monthly =
async { tradeService . fetchPeriodChartData ( stock . code , "M" , true ). getOrDefault ( emptyList ()) }
delay ( 20 )
TechnicalAnalyzer (). apply {
this . daily = dailyData
delay ( 50 )
this . min30 = min30 . await ()
delay ( 50 )
this . weekly = weekly . await ()
delay ( 50 )
this . monthly = monthly . await ()
}
2026-02-06 17:53:17 +09:00
}
2026-05-26 11:20:37 +09:00
if ( analyzer . isValid ()) {
2026-06-19 14:31:33 +09:00
2026-05-26 11:20:37 +09:00
println ( "✅ [분석 시작] ${stock.name} ( ${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()} )" )
RagService . processStock ( currentPrice , analyzer , stock . name , stock . code ) { decision , isSuccess ->
callback ( decision ?. apply { this . currentPrice = currentPrice }, isSuccess )
}
} else {
println ( "✅ [분석 실패] ${stock.name} ( ${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()} )" )
2026-03-23 10:54:54 +09:00
}
2026-05-26 11:20:37 +09:00
} else {
println ( "재무 안정성 부족 (캐시)" )
2026-02-06 17:53:17 +09:00
}
println ( "✅ [분석 종료] ${stock.name} ( ${LocalTime.now()} )" )
}
} catch ( e : Exception ) {
println ( "❌ [Stock Error] ${stock.name} : ${e.message} " )
}
}
private suspend fun fetchCandidates ( tradeService : KisTradeService ): List < RankingStock > = coroutineScope {
listOf (
async { tradeService . fetchMarketRanking ( RankingType . 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 ()) },
async { tradeService . fetchMarketRanking ( RankingType . VALUE , true ). getOrDefault ( emptyList ()) },
async { tradeService . fetchMarketRanking ( RankingType . VOLUME_POWER , 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-06-02 14:40:33 +09:00
private suspend fun executeClosingLiquidation ( activeTrades : List < AutoTradeItem >) {
2026-02-20 15:21:38 +09:00
activeTrades . forEach { trade ->
try {
2026-06-02 14:40:33 +09:00
DatabaseFactory . updateStatusAndOrderNo ( trade . id !! , TradeStatus . EXPIRED )
2026-02-20 15:21:38 +09:00
println ( "📢 [마감 정리 체크] ${trade.name} " )
} catch ( e : Exception ) {
println ( "⚠️ [마감 에러] ${trade.name} : ${e.message} " )
}
2026-06-02 15:30:17 +09:00
delay ( 5 )
2026-02-20 15:21:38 +09:00
}
}
2026-02-05 15:37:11 +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-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
)
}