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-03-20 17:55:27 +09:00
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
2026-03-26 14:42:39 +09:00
import androidx.compose.runtime.remember
2026-03-20 17:55:27 +09:00
import androidx.compose.runtime.setValue
2026-03-13 10:41:10 +09:00
import getLlamaBinPath
2026-01-22 16:21:18 +09:00
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
2026-02-03 18:07:18 +09:00
import kotlinx.coroutines.Job
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.SupervisorJob
2026-02-05 14:26:02 +09:00
import kotlinx.coroutines.TimeoutCancellationException
2026-02-03 18:07:18 +09:00
import kotlinx.coroutines.async
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
2026-02-03 18:07:18 +09:00
import kotlinx.coroutines.delay
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.isActive
2026-01-22 16:21:18 +09:00
import kotlinx.coroutines.launch
2026-02-05 14:26:02 +09:00
import kotlinx.coroutines.withTimeout
2026-02-19 15:47:31 +09:00
import kotlinx.serialization.Serializable
2026-01-22 16:21:18 +09:00
import model.CandleData
2026-02-19 15:47:31 +09:00
import model.ConfigIndex
2026-03-13 16:37:53 +09:00
import model.ExecutionData
2026-02-19 15:47:31 +09:00
import model.KisSession
2026-02-06 17:53:17 +09:00
import model.RankingStock
2026-02-03 18:07:18 +09:00
import model.RankingType
2026-02-19 16:20:15 +09:00
import model.UnifiedBalance
2026-02-05 15:37:11 +09:00
import network.DartCodeManager
2026-02-10 15:08:52 +09:00
import network.FinancialStatement
2026-03-13 10:41:10 +09:00
import network.KisAuthService
2026-02-03 18:07:18 +09:00
import network.KisTradeService
2026-03-13 10:41:10 +09:00
import network.KisWebSocketManager
2026-03-17 10:50:13 +09:00
import network.RagService
2026-03-16 17:07:25 +09:00
import network.StockUniverseLoader
2026-03-26 14:42:39 +09:00
import org.jetbrains.skia.ImageFilter
2026-02-19 15:47:31 +09:00
import util.MarketUtil
2026-01-22 17:56:31 +09:00
import java.time.LocalDateTime
2026-01-22 16:21:18 +09:00
import java.time.LocalTime
2026-01-22 17:56:31 +09:00
import java.time.ZoneId
import java.time.format.DateTimeFormatter
2026-02-06 17:53:17 +09:00
import java.util.concurrent.atomic.AtomicLong
2026-01-22 16:26:29 +09:00
import kotlin.collections.List
2026-01-22 16:21:18 +09:00
2026-01-22 17:56:31 +09:00
import kotlin.math.*
2026-01-22 16:21:18 +09:00
// service/AutoTradingManager.kt
2026-01-23 17:05:09 +09:00
typealias TradingDecisionCallback = ( TradingDecision ? , Boolean ) -> Unit
2026-01-22 16:21:18 +09:00
object AutoTradingManager {
2026-03-27 17:54:21 +09:00
2026-02-06 17:53:17 +09:00
private val scope = CoroutineScope ( Dispatchers . Default + SupervisorJob ( ) )
2026-02-03 18:07:18 +09:00
private var discoveryJob : Job ? = null
2026-01-22 16:21:18 +09:00
2026-02-06 17:53:17 +09:00
// 모니터링을 위한 상태 변수
private val lastTickTime = AtomicLong ( System . currentTimeMillis ( ) )
private var watchdogJob : Job ? = null
2026-03-16 17:07:25 +09:00
private const val CYCLE _TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분
2026-02-06 17:53:17 +09:00
private const val WATCHDOG _CHECK _INTERVAL = 30 * 1000L // 30초마다 생존 확인
2026-03-16 17:07:25 +09:00
private const val STUCK _THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
private const val ONE _STOCK _ALYSIS _TIME = 180000L
2026-02-06 17:53:17 +09:00
fun isRunning ( ) : Boolean = discoveryJob ?. isActive == true
2026-02-10 15:08:52 +09:00
private var remainingCandidates = mutableListOf < RankingStock > ( )
2026-02-13 13:49:40 +09:00
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
2026-02-12 15:31:34 +09:00
private val reanalysisList = mutableListOf < RankingStock > ( )
private val retryCountMap = mutableMapOf < String , Int > ( )
2026-03-20 17:55:27 +09:00
var shouldShowFullWindow by mutableStateOf ( false )
2026-03-26 14:42:39 +09:00
var llmAnalyser by mutableStateOf ( false )
var llmNews by mutableStateOf ( false )
var tradeToken by mutableStateOf ( false )
var webSocketConnect by mutableStateOf ( false )
2026-03-27 10:59:59 +09:00
var testFlag = false
2026-03-30 16:00:51 +09:00
2026-03-20 17:55:27 +09:00
fun startBackgroundScheduler ( ) {
2026-03-27 10:59:59 +09:00
scope . launch {
while ( isActive ) {
val now = LocalTime . now ( ZoneId . of ( " Asia/Seoul " ) )
if ( now . isAfter ( H08M30 ) && now . isBefore ( H18 ) && ! shouldShowFullWindow ) {
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
}
delay ( 60 _000 * 3 ) // 1분마다 체크
}
}
2026-03-20 17:55:27 +09:00
}
2026-03-13 16:37:53 +09:00
val globalCallback = { completeTradingDecision : TradingDecision ? , isSuccess : Boolean ->
if ( isSuccess && completeTradingDecision != null ) {
// 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
TradingLogStore . addLog ( completeTradingDecision )
println ( " 🚀 [자동매수 실행] ${completeTradingDecision.stockName} " )
2026-03-23 10:54:54 +09:00
if ( completeTradingDecision . confidence < 10 ) {
addToReanalysis ( RankingStock ( mksc _shrn _iscd = completeTradingDecision . stockCode , hts _kor _isnm = completeTradingDecision . stockName ) )
2026-04-02 14:05:14 +09:00
TradingLogStore . addLog ( completeTradingDecision , " RETRY " , " 분석 신뢰도 오류 인지로 재분석 대기열에 추가 " )
2026-03-23 10:54:54 +09:00
} else if ( completeTradingDecision != null && ! completeTradingDecision . stockCode . isNullOrEmpty ( ) ) {
2026-03-13 16:37:53 +09:00
var basePrice = completeTradingDecision . currentPrice
var stockCode = completeTradingDecision . stockCode
println ( " basePrice $basePrice " )
val minScore = KisSession . config . getValues ( ConfigIndex . MIN _PURCHASE _SCORE _INDEX )
var maxBudget = KisSession . config . getValues ( ConfigIndex . MAX _BUDGET _INDEX )
val buyWeight = KisSession . config . getValues ( ConfigIndex . BUY _WEIGHT _INDEX )
val baseProfit = KisSession . config . getValues ( ConfigIndex . PROFIT _INDEX )
fun resultCheck ( completeTradingDecision : TradingDecision ) {
val weights = mapOf (
" short " to 0.2 , // 초단기 점수가 낮아도 전체에 미치는 영향 감소
" profit " to 0.4 ,
" safe " to 0.4 // 중장기 점수 비중 강화
)
val totalScore =
( ( completeTradingDecision . shortPossible ( ) + append ) * weights [ " short " ] !! ) +
( ( completeTradingDecision . profitPossible ( ) + append ) * weights [ " profit " ] !! ) +
( ( completeTradingDecision . safePossible ( ) + append ) * weights [ " safe " ] !! )
if ( totalScore >= minScore && completeTradingDecision . confidence >= MIN _CONFIDENCE ) {
var investmentGrade : InvestmentGrade = AutoTradingManager . getInvestmentGrade ( completeTradingDecision , totalScore , completeTradingDecision . confidence )
val finalMargin = baseProfit * KisSession . config . getValues ( investmentGrade . profitGuide )
println ( " 🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode} " )
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
2026-04-02 15:22:38 +09:00
val gradeRate = KisSession . config . getValues ( investmentGrade . allocationRate )
2026-03-13 16:37:53 +09:00
val maxQty = ( KisSession . config . getValues ( ConfigIndex . MAX _COUNT _INDEX ) * gradeRate ) . roundToInt ( )
maxBudget = maxBudget * gradeRate
val calculatedQty = if ( basePrice > 0 ) {
( maxBudget / basePrice ) . toInt ( ) . coerceAtLeast ( 1 )
} else {
1
}
// 5. 매수 실행 (계산된 finalMargin 전달)
2026-03-13 17:34:48 +09:00
excuteTrade (
2026-03-13 16:37:53 +09:00
decision = completeTradingDecision ,
orderQty = min ( calculatedQty , maxQty ) . toString ( ) ,
profitRate1 = finalMargin ,
investmentGrade = investmentGrade ,
)
2026-04-02 15:22:38 +09:00
} else if ( totalScore >= ( minScore * 0.9 ) && completeTradingDecision . confidence + append >= ( MIN _CONFIDENCE * 0.9 ) ) {
2026-03-13 17:34:48 +09:00
addToReanalysis ( RankingStock ( mksc _shrn _iscd = completeTradingDecision . stockCode , hts _kor _isnm = completeTradingDecision . stockName ) )
2026-04-02 14:05:14 +09:00
TradingLogStore . addLog ( completeTradingDecision , " RETRY " , " ✋ [관망] 토탈 스코어[ $totalScore ] 또는 신뢰도[ ${completeTradingDecision.confidence} ] 미달 이나 약간의 오차로 재분석 대기열에 추가 " )
2026-03-13 16:37:53 +09:00
} else {
TradingLogStore . addLog ( completeTradingDecision , " HOLD " , " ✋ [관망] 토탈 스코어( ${String.format("%.1f[${minScore} ] " , totalScore ) } ) 또는 신뢰도 ( $ { String . format ( " %.1f[ ${MIN_CONFIDENCE} ] " , completeTradingDecision . confidence ) } ) 미달 " )
}
}
2026-03-23 10:54:54 +09:00
if ( completeTradingDecision ?. decision ?. contains ( " 매수 " ) == true ) {
completeTradingDecision . decision = " BUY "
}
2026-03-13 16:37:53 +09:00
when ( completeTradingDecision ?. decision ) {
2026-03-23 10:54:54 +09:00
" BUY " , " 매수 " -> {
2026-03-13 16:37:53 +09:00
append = buyWeight
2026-04-02 15:22:38 +09:00
TradingLogStore . addLog ( completeTradingDecision , " BUY " , " [ $stockCode ] 매수 추천 : ${completeTradingDecision?.reason} " )
2026-03-13 16:37:53 +09:00
resultCheck ( completeTradingDecision )
}
2026-03-26 14:42:39 +09:00
" SELL " -> {
2026-04-02 15:22:38 +09:00
TradingLogStore . addLog ( completeTradingDecision , " SELL " , " [ $stockCode ] 매도 추천 : ${completeTradingDecision?.reason} " )
2026-03-26 14:42:39 +09:00
println ( " [ $stockCode ] 매도: ${completeTradingDecision?.reason} " )
}
2026-03-13 16:37:53 +09:00
" HOLD " -> {
append = 0.0
2026-04-02 15:22:38 +09:00
TradingLogStore . addLog ( completeTradingDecision , " HOLD " , " [ $stockCode ] 관망 유지 : ${completeTradingDecision?.reason} " )
2026-03-13 16:37:53 +09:00
resultCheck ( completeTradingDecision )
}
else -> {
append = 0.0
2026-04-02 15:22:38 +09:00
println ( " [ $stockCode ] ${completeTradingDecision?.decision} : ${completeTradingDecision?.reason} " )
2026-03-13 16:37:53 +09:00
}
}
}
}
}
val MIN _CONFIDENCE = 70.0 // 최소 신뢰도
var append = 0.0
fun getInvestmentGrade (
ts : TradingDecision ,
totalScore : Double ,
confidence : Double
) : InvestmentGrade {
// 1. 기본 조건 충족 여부
if ( totalScore < 68.0 || confidence < 70.0 ) {
return InvestmentGrade . LEVEL _1 _SPECULATIVE // 매도/관망 (추천 등급 없음)
}
// 2. 단기/중기/장기 패턴 기준
val ultraShort = ts . ultraShortScore
val short = ts . shortTermScore
val mid = ts . midTermScore
val long = ts . longTermScore
val shortAvg = listOf ( ultraShort , short ) . average ( ) // 초단기+단기
val midLongAvg = listOf ( mid , long ) . average ( ) // 중기+장기
return when {
// LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음
shortAvg >= 85.0 && midLongAvg >= 80.0 ->
if ( ts . analyzer ?. isOverheatedStock ( ) ?: true ) InvestmentGrade . LEVEL _4 _BALANCED _RECOMMEND else InvestmentGrade . LEVEL _5 _STRONG _RECOMMEND
// LEVEL_4: 중기·장기 기본 준수, 단기까지 양호
midLongAvg >= 75.0 && shortAvg >= 70.0 ->
if ( ts . analyzer ?. isOverheatedStock ( ) ?: true ) InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND else InvestmentGrade . LEVEL _4 _BALANCED _RECOMMEND
// LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형
midLongAvg >= 70.0 && shortAvg in 60.0 .. 70.0 ->
if ( ts . analyzer ?. isOverheatedStock ( ) ?: true ) InvestmentGrade . LEVEL _2 _HIGH _RISK else InvestmentGrade . LEVEL _3 _CAUTIOUS _RECOMMEND
// LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매
shortAvg >= 75.0 && midLongAvg < 65.0 ->
if ( ts . analyzer ?. isOverheatedStock ( ) ?: true ) InvestmentGrade . LEVEL _1 _SPECULATIVE else InvestmentGrade . LEVEL _2 _HIGH _RISK
// LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함
shortAvg >= 70.0 && midLongAvg < 55.0 ->
InvestmentGrade . LEVEL _1 _SPECULATIVE
// 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립)
else ->
if ( ts . analyzer ?. isOverheatedStock ( ) ?: true ) InvestmentGrade . LEVEL _1 _SPECULATIVE else InvestmentGrade . LEVEL _2 _HIGH _RISK
}
}
fun excuteTrade ( decision : TradingDecision , orderQty : String , profitRate1 : Double ? , investmentGrade : InvestmentGrade = InvestmentGrade . LEVEL _2 _HIGH _RISK ) {
scope . launch {
var basePrice = decision . currentPrice
val tickSize = MarketUtil . getTickSize ( basePrice )
val oneTickLowerPrice = basePrice - ( tickSize * KisSession . config . getValues ( investmentGrade . buyGuide ) . toInt ( ) )
var stockCode = decision . stockCode
var stockName = decision . stockName
val finalPrice = MarketUtil . roundToTickSize ( oneTickLowerPrice . toDouble ( ) )
println ( " basePrice : $basePrice , oneTickLowerPrice : $oneTickLowerPrice , finalPrice : $finalPrice " )
KisTradeService . postOrder ( stockCode , orderQty , finalPrice . toLong ( ) . toString ( ) , isBuy = true )
. onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
println ( " 주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice " )
TradingLogStore . addLog ( decision , " BUY " , " 주문 성공: $realOrderNo " )
val pRate = 0.4
val sRate = - 1.5
var tax = KisSession . config . getValues ( ConfigIndex . TAX _INDEX )
val effectiveProfitRate = maxOf ( ( ( profitRate1 ?: pRate ) + tax ) , ( KisSession . config . getValues (
ConfigIndex . PROFIT _INDEX ) + tax ) )
val calculatedTarget = MarketUtil . roundToTickSize ( basePrice * ( 1 + effectiveProfitRate / 100.0 ) )
val calculatedStop = MarketUtil . roundToTickSize ( basePrice * ( 1 + sRate / 100.0 ) )
val inputQty = orderQty . replace ( " , " , " " ) . toIntOrNull ( ) ?: 0
DatabaseFactory . saveAutoTrade ( AutoTradeItem (
orderNo = realOrderNo ,
code = stockCode ,
name = stockName ,
quantity = inputQty ,
profitRate = effectiveProfitRate , // 보정된 수익률 저장
stopLossRate = sRate ,
targetPrice = calculatedTarget ,
stopLossPrice = calculatedStop ,
status = " PENDING_BUY " ,
isDomestic = true
) )
syncAndExecute ( realOrderNo )
TradingLogStore . addLog ( decision , " BUY " , " 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)} %): $realOrderNo " )
}
. onFailure {
println ( " 매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice " )
2026-04-01 14:35:56 +09:00
if ( it . message ?. contains ( " 주문가능금액을 초과 " ) == true ) {
AutoTradingManager . addToReanalysis ( RankingStock ( mksc _shrn _iscd = stockCode , hts _kor _isnm = stockName ) )
2026-04-02 14:05:14 +09:00
TradingLogStore . addLog ( decision , " WATCH " , " ${it.message ?: " 매수 실패"} => 재분석 대기열에 추가 " )
2026-04-01 14:35:56 +09:00
} else {
TradingLogStore . addLog ( decision , " BUY " , it . message ?: " 매수 실패 " )
}
2026-03-13 16:37:53 +09:00
}
}
}
2026-03-13 17:34:48 +09:00
var onExecutionReceived : ( ( String , String , String , String , Boolean ) -> Unit ) ? = { code , qty , price , orderNo , isBuy ->
scope . launch {
val exec = ExecutionData ( orderNo , code , price , qty , isBuy )
executionCache [ orderNo ] = exec
syncAndExecute ( orderNo )
}
}
2026-03-13 16:37:53 +09:00
val executionCache = mutableMapOf < String , ExecutionData > ( )
val processingIds = mutableSetOf < String > ( ) // 주문번호 기준 잠금
suspend fun syncAndExecute ( orderNo : String ) {
if ( processingIds . contains ( orderNo ) ) return
processingIds . add ( orderNo )
try {
val dbItem = DatabaseFactory . findByOrderNo ( orderNo )
val execData = executionCache [ orderNo ]
if ( dbItem != null && execData != null && execData . isFilled ) {
if ( dbItem . status == TradeStatus . PENDING _BUY ) {
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
val actualBuyPrice = execData . price . toDoubleOrNull ( ) ?: dbItem . targetPrice
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
val minEffectiveRate = KisSession . config . getValues ( ConfigIndex . PROFIT _INDEX ) + KisSession . config . getValues ( ConfigIndex . TAX _INDEX )
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
val finalProfitRate = maxOf ( dbItem . profitRate , minEffectiveRate )
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil . roundToTickSize ( actualBuyPrice * ( 1 + finalProfitRate / 100.0 ) )
println ( " 🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} ( ${String.format("%.2f", finalProfitRate)} % 적용) " )
KisTradeService . postOrder (
stockCode = dbItem . code ,
qty = dbItem . quantity . toString ( ) ,
price = finalTargetPrice . toLong ( ) . toString ( ) ,
isBuy = false
) . onSuccess { newSellOrderNo ->
// 익절가 업데이트 및 상태 변경
DatabaseFactory . updateStatusAndOrderNo ( dbItem . id !! , TradeStatus . SELLING , newSellOrderNo )
2026-03-13 17:34:48 +09:00
TradingLogStore . addSellLog ( dbItem . name , finalTargetPrice . toString ( ) , " SELL " , " 🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} ( ${String.format("%.2f", finalProfitRate)} % 적용) " )
2026-03-13 16:37:53 +09:00
executionCache . remove ( orderNo )
} . onFailure {
println ( " ❌ 익절 주문 실패: ${it.message} " )
2026-03-13 17:34:48 +09:00
TradingLogStore . addSellLog ( dbItem . name , finalTargetPrice . toString ( ) , " SELL " , " ❌ 익절 주문 실패: ${it.message} " )
2026-03-13 16:37:53 +09:00
}
} else if ( dbItem . status == TradeStatus . SELLING ) {
println ( " 🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name} " )
2026-03-13 17:34:48 +09:00
TradingLogStore . addSellLog ( dbItem . name , execData . price , " SELL " , " 🎊 [매칭 성공] 매도 완료 처리 " )
2026-03-13 16:37:53 +09:00
DatabaseFactory . updateStatusAndOrderNo ( dbItem . id !! , TradeStatus . COMPLETED )
executionCache . remove ( orderNo )
}
}
} finally {
processingIds . remove ( orderNo )
}
}
2026-02-06 17:53:17 +09:00
/ * *
* 자동 발굴 루프 시작 및 Watchdog 실행
* /
2026-03-13 16:37:53 +09:00
fun startAutoDiscoveryLoop ( ) {
2026-02-06 17:53:17 +09:00
if ( isRunning ( ) ) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
watchdogJob ?. cancel ( )
watchdogJob = scope . launch {
while ( isActive ) {
delay ( WATCHDOG _CHECK _INTERVAL )
val now = System . currentTimeMillis ( )
if ( isRunning ( ) && ( now - lastTickTime . get ( ) > STUCK _THRESHOLD ) ) {
println ( " 🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다. " )
2026-03-13 16:37:53 +09:00
restartLoop ( )
2026-02-05 15:37:11 +09:00
}
}
}
2026-02-06 17:53:17 +09:00
// 2. 메인 루프 실행
2026-03-26 13:48:26 +09:00
runDiscoveryLoop ( globalCallback )
2026-02-06 17:53:17 +09:00
}
2026-02-03 18:07:18 +09:00
2026-04-03 17:34:27 +09:00
suspend fun sellingAfterMarketOnePrice ( tradeService : KisTradeService , balance : UnifiedBalance , marketCode : String = " Y " ) {
2026-04-03 17:02:42 +09:00
println ( " sellingAfterMarketOnePrice " )
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-03 17:02:42 +09:00
println ( " ${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} , 주문 가능 : ${holding.availOrderCount} , 수익율 : ${holding.profitRate} " )
2026-04-03 17:47:56 +09:00
if ( holding != null && holding . quantity . toInt ( ) > 0 && holding . availOrderCount . toInt ( ) > 0 && holding . profitRate . toDouble ( ) > 0.5 ) {
2026-03-27 17:54:21 +09:00
println ( " ${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} " )
var targetPrice = holding . currentPrice . toDouble ( )
2026-04-03 17:47:56 +09:00
TradingLogStore . addAfterMarketLog (
holding . name ,
2026-03-27 17:54:21 +09:00
holding . code ,
2026-04-03 17:47:56 +09:00
" ${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상 "
2026-03-27 17:54:21 +09:00
)
2026-04-03 17:47:56 +09:00
targetPrice = MarketUtil . roundToTickSize ( 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 "SOR"
// ).onSuccess { newOrderNo ->
// println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
// TradingLogStore.addSellLog(
// holding.code,
// targetPrice.toString(),
// "SELL",
// "🎊 시간외 단일가 주식 재고털이 주문 완료"
// )
// }.onFailure {
// TradingLogStore.addSellLog(
// holding.code,
// targetPrice.toString(),
// "SELL",
// "🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
// )
// }
2026-04-03 17:02:42 +09:00
}
delay ( 300 ) // API 호출 부하 방지
}
}
}
suspend fun resumePendingSellOrders ( tradeService : KisTradeService , balance : UnifiedBalance ) {
if ( isRunning ( ) ) return
val now = LocalTime . now ( )
val currentMinute = now . minute
if ( now . isBefore ( H16 ) && now . isAfter ( H08M35 ) ) {
println ( " resumePendingSellOrders " )
balance . holdings . forEach { holding ->
if ( BLACKLISTEDSTOCKCODES . contains ( holding . code ) ) {
println ( " ❌ 차단 처리된 주식 : ${holding.name} " )
2026-04-02 14:05:14 +09:00
TradingLogStore . addAnalyzer (
2026-04-03 17:02:42 +09:00
holding . name ,
2026-04-02 14:05:14 +09:00
holding . code ,
2026-04-03 17:02:42 +09:00
" 거랙 차단 대상 : ${holding.currentPrice} [ ${holding.quantity} 주] 보유, 수익률( ${holding.profitRate.toDouble()} ) "
2026-04-02 14:05:14 +09:00
)
2026-04-03 17:02:42 +09:00
} else {
if ( holding != null && holding . quantity . toInt ( ) > 0 && holding . availOrderCount . toInt ( ) > 0 && holding . profitRate . toDouble ( ) > KisSession . config . SELL _PROFIT ) {
println ( " ${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} " )
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
var targetPrice = holding . currentPrice . toDouble ( )
val now = LocalTime . now ( )
val currentMinute = now . minute
var isBefore930 = false
if ( now . hour == 9 && currentMinute < 30 ) {
targetPrice = targetPrice
isBefore930 = true
} else {
targetPrice = MarketUtil . roundToTickSize ( targetPrice + MarketUtil . getTickSize ( targetPrice ) )
}
println ( " 🔄 [재주문] ${holding.name} ( ${holding.code} ) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도 " )
tradeService . postOrder (
stockCode = holding . code ,
qty = holding . availOrderCount ,
price = targetPrice . toInt ( ) . toString ( ) ,
isBuy = false ,
) . onSuccess { newOrderNo ->
println ( " ✅ [재주문 완료] ${holding.name} : $newOrderNo " )
TradingLogStore . addSellLog (
holding . code ,
targetPrice . toString ( ) ,
" SELL " ,
" 🎊 보유 주식[예상수익 : ${holding.profitRate} ] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice} }로 매도[ $targetPrice ] 주문 " else " 09:30 이후 시세{ ${holding.currentPrice} } 기준 호가 위 매도[ $targetPrice ] 주문 " } 완료 "
)
} . onFailure {
TradingLogStore . addSellLog (
holding . code ,
targetPrice . toString ( ) ,
" SELL " ,
" 🎊 보유 주식 매도 주문 실패[ ${it.message} ] "
)
}
} else {
TradingLogStore . addAnalyzer (
" 보유주식[ ${holding.name} ] " ,
holding . code ,
" 수익률 미달 : ${holding.currentPrice} [ ${holding.quantity} 주] 보유, 수익률( ${holding.profitRate.toDouble()} ) "
)
}
delay ( 200 ) // API 호출 부하 방지
2026-03-27 17:54:21 +09:00
}
2026-02-19 15:47:31 +09:00
}
}
}
2026-03-13 10:41:10 +09:00
var isSystemReadyToday = false
var isSystemCleanedUpToday = false
private var lastRetryTime = 0L
val binPath = getLlamaBinPath ( )
2026-02-19 15:47:31 +09:00
2026-03-13 10:41:10 +09:00
suspend fun tryRefreshToken ( ) {
try {
// 2분 간격 재시도 로직 (처음 실행 시에는 lastRetryTime이 0이므로 즉시 실행)
if ( currentTimeMillis - lastRetryTime >= 2 * 60 * 1000L ) {
lastRetryTime = currentTimeMillis
println ( " 🌅 [System] 오전 8시 업무 시작 준비 시도... " )
SystemSleepPreventer . wakeDisplay ( ) // 모니터 깨우기
val authSuccess = KisAuthService . refreshAllTokens ( )
val wsSuccess = KisTradeService . refreshWebsocketKey ( )
if ( authSuccess && wsSuccess ) {
println ( " ✅ [System] 토큰 갱신 성공. AI 서버를 기동합니다. " )
// 서버 시작 로직 실행 (Main.kt에 있던 로직 활용)
val config = KisSession . config
// LLM 서버 시작 (설정된 모델 경로 사용)
if ( config . modelPath . isNotEmpty ( ) ) {
2026-03-27 17:45:51 +09:00
LlamaServerManager . startServer ( binPath , config . modelPath , port = LLM _PORT )
2026-03-13 10:41:10 +09:00
}
if ( config . embedModelPath . isNotEmpty ( ) ) {
2026-03-27 17:45:51 +09:00
LlamaServerManager . startServer ( binPath , config . embedModelPath , port = EMBEDDING _PORT )
2026-03-13 10:41:10 +09:00
}
KisWebSocketManager . connect ( )
isSystemReadyToday = true
2026-03-20 17:55:27 +09:00
shouldShowFullWindow = true
2026-03-13 10:41:10 +09:00
} else {
println ( " ❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다. " )
}
}
} catch ( e : Exception ) { }
}
2026-03-19 11:41:21 +09:00
var onMarketClosed : ( ( ) -> Unit ) ? = null
2026-03-13 10:41:10 +09:00
var now = LocalTime . now ( ZoneId . of ( " Asia/Seoul " ) )
var currentTimeMillis = System . currentTimeMillis ( )
var waitTime = 0.2
2026-04-03 17:02:42 +09:00
val H16 = LocalTime . of ( 16 , 0 )
2026-03-26 15:40:46 +09:00
val H18 = LocalTime . of ( 18 , 0 )
2026-03-26 13:48:26 +09:00
val H08M35 = LocalTime . of ( 8 , 35 )
2026-03-19 17:00:53 +09:00
val H08M30 = LocalTime . of ( 8 , 30 )
2026-03-26 13:48:26 +09:00
private fun runDiscoveryLoop ( callback : TradingDecisionCallback ) {
2026-02-03 18:07:18 +09:00
discoveryJob = scope . launch {
2026-02-06 17:53:17 +09:00
println ( " 🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()} " )
while ( isActive ) {
2026-02-03 18:07:18 +09:00
try {
2026-03-13 10:41:10 +09:00
now = LocalTime . now ( ZoneId . of ( " Asia/Seoul " ) )
currentTimeMillis = System . currentTimeMillis ( )
2026-02-06 17:53:17 +09:00
lastTickTime . set ( System . currentTimeMillis ( ) ) // 생존 신고
2026-03-13 10:41:10 +09:00
when {
2026-03-26 15:40:46 +09:00
now . isAfter ( H18 ) || now . isBefore ( H08M35 ) -> {
2026-03-26 13:48:26 +09:00
prepareMarketOpen ( now )
}
2026-03-26 15:40:46 +09:00
now . isBefore ( H18 ) && now . isAfter ( H08M35 ) -> {
2026-03-13 10:41:10 +09:00
waitTime = 0.2
if ( now . isAfter ( LocalTime . of ( 8 , 0 ) ) && now . isBefore ( LocalTime . of ( 15 , 30 ) ) ) {
if ( ! KisSession . isMarketTokenValid ( ) || ! KisSession . isTradeTokenValid ( ) ) {
if ( isSystemReadyToday ) {
println ( " ⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다. " )
isSystemReadyToday = false
KisWebSocketManager . disconnect ( )
tryRefreshToken ( )
}
}
}
withTimeout ( CYCLE _TIMEOUT ) {
println ( " ⏱️ [Cycle Start] ${LocalTime.now()} " )
2026-03-26 15:40:46 +09:00
if ( now . isAfter ( H18 ) ) {
2026-03-26 13:48:26 +09:00
executeClosingLiquidation ( KisTradeService )
2026-03-13 10:41:10 +09:00
} else {
2026-03-26 13:48:26 +09:00
executeMarketLoop ( )
2026-03-13 10:41:10 +09:00
}
2026-02-09 15:32:31 +09:00
}
2026-02-03 18:07:18 +09:00
}
2026-03-26 13:48:26 +09:00
//
// //장외
// now.isAfter(H16) || now.isBefore(H08M35) -> {
// finalizeMarketClose(now)
// }
2026-03-13 10:41:10 +09:00
else -> {
waitTime = 3.0
}
2026-02-03 18:07:18 +09:00
}
2026-02-06 17:53:17 +09:00
} catch ( e : TimeoutCancellationException ) {
println ( " ⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다. " )
} catch ( e : Exception ) {
println ( " ⚠️ [Loop Error] ${e.message} " )
2026-02-24 13:14:11 +09:00
delay ( 1500 )
2026-02-06 17:53:17 +09:00
}
2026-03-13 10:41:10 +09:00
waitForNextCycle ( waitTime )
2026-02-06 17:53:17 +09:00
}
}
}
2026-02-04 14:52:09 +09:00
2026-03-26 13:48:26 +09:00
suspend fun prepareMarketOpen ( now : LocalTime ) {
2026-03-26 15:40:46 +09:00
if ( now . isAfter ( H18 ) || now . isBefore ( H08M30 ) ) {
2026-03-26 13:48:26 +09:00
println ( " 🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다. " )
onMarketClosed ?. invoke ( )
KisWebSocketManager . disconnect ( )
BrowserManager . closeIfIdle ( 0 )
LlamaServerManager . stopAll ( ) // AI 서버 완전 종료
TradingLogStore . clear ( )
2026-03-26 15:40:46 +09:00
isSystemReadyToday = false
shouldShowFullWindow = false
2026-03-26 13:48:26 +09:00
stopDiscovery ( ) // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if ( now . isAfter ( H08M30 ) && now . isBefore ( H08M35 ) && !is SystemReadyToday ) {
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 > > {
val count = minOf ( loadedTops . size , 100 )
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-03 17:02:42 +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-03 17:34:27 +09:00
return balance
2026-04-03 17:02:42 +09:00
} else {
2026-04-03 17:47:56 +09:00
listOf < String > ( " Y " , " X " ) . forEach { code ->
KisTradeService . fetchIntegratedBalance ( code ) . getOrNull ( ) ?. let {
sellingAfterMarketOnePrice ( KisTradeService , it , code )
}
delay ( 1000 )
}
2026-04-03 17:02:42 +09:00
}
2026-04-03 17:34:27 +09:00
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-03-27 17:54:21 +09:00
if ( AUTOSELL ) balance ?. let { resumePendingSellOrders ( KisTradeService , it ) }
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-03 17:02:42 +09:00
if ( now . isBefore ( H16 ) && now . isAfter ( H08M35 ) ) {
if ( BLACKLISTEDSTOCKCODES . contains ( stock . code ) ) {
println ( " ❌ 차단 처리된 주식 : ${stock.name} " )
} else {
try {
processSingleStock ( stock , myCash , KisTradeService , globalCallback )
} catch ( e : Exception ) {
println ( " ❌ 처리 중 오류 발생 (건너뜀): ${stock.name} " )
} finally {
iterator . remove ( )
}
println ( " 남은 후보군 개수 : ${totalCount} " )
delay ( 100 )
2026-03-27 17:54:21 +09:00
}
2026-03-26 13:48:26 +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 ( " 매도 스케줄 체크 " )
if ( now . hour == 9 && ( currentMinute == 1 || currentMinute == 15 || currentMinute == 40 ) ) {
if ( lastForceCheckMinute != currentMinute ) {
TradingLogStore . addAnalyzer ( " - " , " - " , " ⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute} 분 - 보유주식 매도 체크를 시작합니다. " , true )
println ( " ⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute} 분 - 보유주식 매도 체크를 시작합니다. " )
checkBalance ( )
lastForceCheckMinute = currentMinute // 실행 완료 기록
}
}
2026-04-03 17:47:56 +09:00
else if ( ( now . hour == 16 || now . hour == 17 ) && ( currentMinute % 10 == 3 || currentMinute % 10 == 9 ) ) {
2026-04-03 17:02:42 +09:00
if ( lastForceCheckMinute != currentMinute ) {
TradingLogStore . addAnalyzer (
" - " ,
" - " ,
2026-04-03 17:47:56 +09:00
" ⏰ [강제 스케줄 실행] 오후 ${now.hour} 시 ${currentMinute} 분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다. " ,
2026-04-03 17:02:42 +09:00
true
)
checkBalance ( false )
lastForceCheckMinute = currentMinute // 실행 완료 기록
}
}
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-02-13 13:49:40 +09:00
fun addStock ( currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer , stockName : String , stockCode : String , result : TradingDecisionCallback ) {
2026-01-22 16:21:18 +09:00
scope . launch {
2026-02-13 13:49:40 +09:00
RagService . processStock ( currentPrice , technicalAnalyzer , stockName , stockCode , result )
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
object FinancialAnalyzer {
fun isSafetyBeltMet ( fs : FinancialStatement ) : Boolean {
val isDebtSafe = fs . debtRatio < 200.0 // 부채비율 200% 미만
val isLiquiditySafe = fs . quickRatio > 80.0 // 당좌비율 80% 이상
val isNotDeficit = fs . isNetIncomePositive // 당기순이익은 일단 흑자여야 함
2026-04-02 11:16:53 +09:00
val isNotCrashing = fs . netIncomeGrowth > - 40.0
return isDebtSafe && isLiquiditySafe && isNotDeficit && isNotCrashing
2026-02-10 15:08:52 +09:00
}
/ * *
* [ 매수 고려 ] 우량 기업 요건 확인
* 모든 조건 충족 시 적극적인 분석 ( AI / 차트 ) 단계로 진입합니다 .
* /
fun isBuyConsiderationMet ( fs : FinancialStatement ) : Boolean {
val highProfitability = fs . roe >= 10.0 // ROE 10% 이상
val strongGrowth = fs . netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
val verySafeDebt = fs . debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
val goodLiquidity = fs . quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
val businessHealthy = fs . isOperatingProfitPositive // 본업(영업이익)이 흑자
return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy
}
2026-03-26 14:42:39 +09:00
fun toString ( fs : FinancialStatement ) : String {
var buffer = StringBuffer ( )
val isDebtSafe = fs . debtRatio < 200.0 // 부채비율 200% 미만
val isLiquiditySafe = fs . quickRatio > 80.0 // 당좌비율 80% 이상
val isNotDeficit = fs . isNetIncomePositive // 당기순이익은 일단 흑자여야 함
2026-04-02 11:16:53 +09:00
val isNotCrashing = fs . netIncomeGrowth > - 40.0
2026-03-26 14:42:39 +09:00
if ( ( isDebtSafe && isLiquiditySafe && isNotDeficit ) == false ) {
2026-04-02 11:16:53 +09:00
if ( !is DebtSafe ) buffer . appendLine ( " 부채비율 200% 이상 " )
if ( !is LiquiditySafe ) buffer . appendLine ( " 당좌비율 80% 미만 " )
if ( !is NotDeficit ) buffer . appendLine ( " 당기순이익 적자 " )
if ( !is NotCrashing ) { buffer . appendLine ( " 당기순이익 급감( ${String.format("%.1f", fs.netIncomeGrowth)} %) " ) }
2026-03-26 14:42:39 +09:00
buffer . appendLine ( " 최소 기준 미달 " )
} else {
buffer . appendLine ( " 최소 기준 충족 " )
}
val highProfitability = fs . roe >= 10.0 // ROE 10% 이상
val strongGrowth = fs . netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
val verySafeDebt = fs . debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
val goodLiquidity = fs . quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
val businessHealthy = fs . isOperatingProfitPositive // 본업(영업이익)이 흑자
if ( ( highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy ) == false ) {
if ( ! highProfitability ) buffer . appendLine ( " ROE 10% 미만 " )
if ( ! strongGrowth ) buffer . appendLine ( " 이익 성장률 15% 미만 " )
if ( ! verySafeDebt ) buffer . appendLine ( " 부채비율 100% 이상 (안전성 미달) " )
if ( ! goodLiquidity ) buffer . appendLine ( " 당좌비율 120% 이하 (여유 없음) " )
if ( ! businessHealthy ) buffer . appendLine ( " 본업(영업이익)이 적자 " )
buffer . appendLine ( " 재무 건전성 및 성장성 미달 " )
} else {
buffer . appendLine ( " 재무 건전성 및 성장성 충족 " )
}
return buffer . toString ( )
}
2026-02-10 15:08:52 +09:00
/ * *
* 종합 상태 반환 ( UI 또는 로그용 )
* /
fun getInvestmentStatus ( fs : FinancialStatement ) : String {
return when {
isBuyConsiderationMet ( fs ) -> " 🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수 "
isSafetyBeltMet ( fs ) -> " ⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족 "
else -> " 🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업 "
}
}
fun calculateScore ( fs : FinancialStatement ) : Int {
var score = 50.0 // 기본 점수
// 성장성 (영업이익 증가율)
score += when {
fs . operatingProfitGrowth > 20 -> 20
fs . operatingProfitGrowth > 0 -> 10
else -> - 10 // 역성장 시 감점
}
// 수익성 (ROE)
score += when {
fs . roe > 15 -> 15
fs . roe > 5 -> 5
fs . roe < 0 -> - 15 // 적자 시 큰 감점
else -> 0
}
// 안정성 (부채비율)
score += when {
fs . debtRatio < 100 -> 15
fs . debtRatio < 200 -> 5
else -> - 10
}
// 유동성 (당좌비율)
if ( fs . quickRatio < 100 ) score -= 10 // 단기 채무 지급 능력 부족 시 감점
return score . coerceIn ( 0.0 , 100.0 ) . toInt ( )
}
}
2026-02-05 14:26:02 +09:00
data class InvestmentScores (
val ultraShort : Int , // 초단기 (분봉/에너지)
val shortTerm : Int , // 단기 (일봉/뉴스)
val midTerm : Int , // 중기 (주봉/재무)
val longTerm : Int // 장기 (월봉/펀더멘털)
2026-02-10 15:08:52 +09:00
) {
override fun toString ( ) : String {
return """
2026-03-26 14:42:39 +09:00
평점 : $ { avg ( ) }
초단 : $ ultraShort
단기 : $ shortTerm
중기 : $ midTerm
장기 : $ longTerm
2026-02-10 15:08:52 +09:00
""" .trimIndent()
}
2026-03-23 10:54:54 +09:00
fun avg ( ) = listOf ( ultraShort , shortTerm , midTerm , longTerm ) . average ( )
2026-03-26 13:48:26 +09:00
2026-02-10 15:08:52 +09:00
}
2026-02-19 15:47:31 +09:00
@Serializable
2026-02-04 14:52:09 +09:00
class TechnicalAnalyzer {
2026-01-22 16:21:18 +09:00
var monthly : List < CandleData > = emptyList ( )
var weekly : List < CandleData > = emptyList ( )
var daily : List < CandleData > = emptyList ( )
var min30 : List < CandleData > = emptyList ( )
2026-01-22 16:26:29 +09:00
2026-03-23 10:54:54 +09:00
fun isValid ( ) = listOf ( min30 , monthly , weekly , daily ) . filter { it . size > 0 } . size == 4
2026-02-19 15:47:31 +09:00
fun isOverheatedStock ( ) : Boolean {
if ( min30 . size < 20 || daily . size < 20 ) return false
val currentPrice = min30 . last ( ) . stck _prpr . toDouble ( )
// 1. 일봉 기준 이격도 체크 (20일 이평선 대비)
val ma20Daily = daily . takeLast ( 20 ) . map { it . stck _prpr . toDouble ( ) } . average ( )
val disparityDaily = ( currentPrice / ma20Daily ) * 100
// 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열)
if ( disparityDaily > 125.0 ) return true
2026-02-05 14:26:02 +09:00
2026-02-19 15:47:31 +09:00
// 2. 분봉(30분봉) 기준 단기 급등 체크
val startPrice30 = min30 . first ( ) . stck _oprc . toDouble ( )
val riseRate30 = ( ( currentPrice - startPrice30 ) / startPrice30 ) * 100
// 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험
if ( riseRate30 > 15.0 ) return true
// 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑)
val avgVol = min30 . dropLast ( 3 ) . map { it . cntg _vol . toDouble ( ) } . average ( )
val recentVol = min30 . last ( ) . cntg _vol . toDouble ( )
// 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음
if ( recentVol > avgVol * 10 ) return true
// 4. 볼린저 밴드 상단 이탈 강도
// ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인
return false
}
2026-01-22 17:56:31 +09:00
fun calculateScores (
financialScore : Int // 재무제표 점수 (성장률 등 기반)
) : InvestmentScores {
// 1. 초단기 (분봉 + 에너지 지표 위주)
2026-04-02 11:16:53 +09:00
var ultra = ( calculateMFI ( min30 , 14 ) * 0.4 +
2026-02-04 14:52:09 +09:00
calculateStochastic ( min30 ) * 0.3 +
( if ( calculateChange ( min30 . takeLast ( 10 ) ) > 0 ) 30 else 0 ) ) . toInt ( )
2026-01-22 17:56:31 +09:00
// 2. 단기 (일봉 추세 + OBV 에너지)
2026-04-02 11:16:53 +09:00
var short = ( calculateRSI ( daily ) * 0.3 +
2026-02-04 14:52:09 +09:00
( if ( calculateOBV ( daily ) > 0 ) 40 else 10 ) +
( if ( calculateChange ( daily . takeLast ( 3 ) ) > 0 ) 30 else 0 ) ) . toInt ( )
2026-01-22 17:56:31 +09:00
// 3. 중기 (주봉 + 재무 점수 혼합)
2026-04-02 11:16:53 +09:00
var mid = ( if ( calculateChange ( weekly ) > 0 ) 40 else 10 ) +
2026-01-22 17:56:31 +09:00
( financialScore * 0.6 ) . toInt ( )
// 4. 장기 (월봉 + 섹터/기업 펀더멘털)
2026-04-02 11:16:53 +09:00
var long = ( if ( calculateChange ( monthly ) > 0 ) 50 else 0 ) +
2026-01-22 17:56:31 +09:00
( financialScore * 0.5 ) . toInt ( )
2026-04-02 11:16:53 +09:00
// 1. 일봉 이격도 과열 체크 (20일 이평선 기준)
if ( daily . size >= 20 ) {
val ma20Daily = daily . takeLast ( 20 ) . map { it . stck _prpr . toDouble ( ) } . average ( )
val currentPrice = daily . last ( ) . stck _prpr . toDouble ( )
val disparityDaily = ( currentPrice / ma20Daily ) * 100
if ( disparityDaily > 115.0 ) { // 20일선보다 15% 이상 떠 있으면 감점 시작
2026-04-02 14:05:14 +09:00
val penalty = ( ( disparityDaily - 115.0 ) * 0.5 ) . toInt ( ) // 초과분 1%당 2점 감점
2026-04-02 11:16:53 +09:00
short -= penalty
ultra -= ( penalty / 2 ) // 초단기에도 영향
println ( " ⚠️ [과열 감점] 일봉 이격도( ${String.format("%.1f", disparityDaily)} %): - ${penalty} 점 " )
}
}
// 2. 주봉 급등 체크 (최근 3주간의 상승폭)
if ( weekly . size >= 3 ) {
val weeklyChange = calculateChange ( weekly . takeLast ( 3 ) )
if ( weeklyChange > 30.0 ) { // 3주간 30% 이상 급등 시
2026-04-02 14:05:14 +09:00
mid -= 10
short -= 5
println ( " ⚠️ [과열 감점] 주봉 급등( ${String.format("%.1f", weeklyChange)} %): -10점 " )
2026-04-02 11:16:53 +09:00
}
}
2026-01-22 17:56:31 +09:00
return InvestmentScores (
ultraShort = ultra . coerceIn ( 0 , 100 ) ,
shortTerm = short . coerceIn ( 0 , 100 ) ,
midTerm = mid . coerceIn ( 0 , 100 ) ,
longTerm = long . coerceIn ( 0 , 100 )
)
}
2026-01-22 16:21:18 +09:00
fun generateComprehensiveReport ( ) : String {
// [1] 단기 에너지 지표 계산 (최근 30분봉 기준)
val obv = calculateOBV ( min30 )
val mfi = calculateMFI ( min30 , 14 )
val adLine = calculateADLine ( min30 )
// [2] 시계열별 가격 변동 및 추세 요약
val m10 = min30 . takeLast ( 10 )
val change10 = calculateChange ( m10 )
val change30 = calculateChange ( min30 )
val changeDaily = calculateChange ( daily . takeLast ( 2 ) ) // 전일 대비
// [3] 이평선 및 가격 위치
val ma5 = m10 . takeLast ( 5 ) . map { it . stck _prpr . toDouble ( ) } . average ( )
val currentPrice = min30 . last ( ) . stck _prpr . toDouble ( )
2026-01-23 17:05:09 +09:00
val signal = ScalpingAnalyzer ( ) . analyze ( min30 . toScalpingList ( ) , isDailyBullish ( ) )
2026-01-22 16:21:18 +09:00
// [4] 거래량 강도
val avgVol30 = min30 . map { it . cntg _vol . toLong ( ) } . average ( )
val recentVol5 = m10 . takeLast ( 5 ) . map { it . cntg _vol . toLong ( ) } . average ( )
val volStrength = if ( avgVol30 > 0 ) recentVol5 / avgVol30 else 1.0
2026-01-22 17:56:31 +09:00
val atr = calculateATR ( min30 )
val stochK = calculateStochastic ( min30 )
val priceRange30 = min30 . maxOf { it . stck _hgpr . toDouble ( ) } - min30 . minOf { it . stck _lwpr . toDouble ( ) }
2026-01-22 16:21:18 +09:00
return """
2026-01-23 17:05:09 +09:00
- 초 / 단타 종합 스코어 : $ { signal . compositeScore } / 100
- 초 / 단타 매수 신호 발생 여부 : $ { if ( signal . buySignal ) " YES " else " NO " }
- 초 / 단타 성공 확률 예측 : $ { signal . successProbPct } %
- 초 / 단타 위험 등급 : $ { signal . riskLevel } ( ATR 변동성 기반 )
- 초 / 단타 RSI : $ { " %.1f " . format ( signal . rsi ) } / 거래량 비율 : $ { " %.1f " . format ( signal . volRatio ) } 배
- 초 / 단타 권장 가격 : 손절가 ( $ { signal . suggestedSlPrice . toInt ( ) } 원 ) , 익절가 ( $ { signal . suggestedTpPrice . toInt ( ) } 원 )
2026-01-22 17:56:31 +09:00
- 월봉 / 주봉 위치 : $ { if ( calculateChange ( monthly ) > 0 ) " 장기 상승 " else " 장기 하락 " } / $ { if ( calculateChange ( weekly ) > 0 ) " 중기 상승 " else " 중기 하락 " }
- 일봉 대비 : $ { " %.2f " . format ( changeDaily ) } % 변동
- 30 분 대비 : $ { " %.2f " . format ( change30 ) } % 변동
- 10 분 대비 : $ { " %.2f " . format ( change10 ) } % 변동
- 이평선 상태 : 현재가 ( $ { currentPrice . toInt ( ) } ) vs MA5 ( $ { ma5 . toInt ( ) } ) -> $ { if ( currentPrice > ma5 ) " 상단 위치 " else " 하단 위치 " }
2026-01-23 17:05:09 +09:00
- OBV ( 누적 거래량 에너지 ) : $ { " %.0f " . format ( obv ) }
- MFI ( 자금 유입 지수 ) : $ { " %.1f " . format ( mfi ) }
- A / D ( 누적 분산 라인 ) : $ { " %.0f " . format ( adLine ) }
2026-01-22 17:56:31 +09:00
- 거래량 강도 : 최근 5 분 평균이 30 분 평균의 $ { " %.1f " . format ( volStrength ) } 배 수준
2026-01-23 17:05:09 +09:00
- ATR ( 평균 변동폭 ) : $ { " %.0f " . format ( atr ) } 원
- 30 분 내 최대 진폭 : $ { " %.0f " . format ( priceRange30 ) } 원
- 스토캐스틱 ( % K ) : $ { " %.1f " . format ( stochK ) }
- 변동성 강도 : 현재 진폭이 ATR 대비 $ { " %.1f " . format ( priceRange30 / atr ) } 배 수준
2026-01-22 17:56:31 +09:00
- 30 분봉 최고가 : $ { min30 . maxOf { it . stck _hgpr . toInt ( ) } }
- 30 분봉 최저가 : $ { min30 . minOf { it . stck _lwpr . toInt ( ) } }
- RSI ( 14 ) : $ { " %.1f " . format ( calculateRSI ( min30 ) ) }
2026-01-23 17:05:09 +09:00
""" .trimIndent()
2026-01-22 16:21:18 +09:00
}
2026-01-22 17:56:31 +09:00
/ * *
* ATR ( Average True Range ) : 최근 변동 폭의 평균 . 그래프의 ' 출렁임 ' 크기를 측정
* /
fun calculateATR ( candles : List < CandleData > , period : Int = 14 ) : Double {
val sub = candles . takeLast ( period + 1 )
val trList = mutableListOf < Double > ( )
for ( i in 1 until sub . size ) {
val high = sub [ i ] . stck _hgpr . toDouble ( )
val low = sub [ i ] . stck _lwpr . toDouble ( )
val prevClose = sub [ i - 1 ] . stck _prpr . toDouble ( )
val tr = maxOf ( high - low , Math . abs ( high - prevClose ) , Math . abs ( low - prevClose ) )
trList . add ( tr )
}
return trList . average ( )
}
/ * *
* Stochastic ( % K ) : 최근 가격 범위 내에서 현재가의 위치 ( 0 ~ 100 )
* 반복되는 파동 ( Ups and Downs ) 에서 현재가 고점인지 저점인지 판단
* /
fun calculateStochastic ( candles : List < CandleData > , period : Int = 14 ) : Double {
val sub = candles . takeLast ( period )
val highest = sub . maxOf { it . stck _hgpr . toDouble ( ) }
val lowest = sub . minOf { it . stck _lwpr . toDouble ( ) }
val current = sub . last ( ) . stck _prpr . toDouble ( )
return if ( highest != lowest ) ( current - lowest ) / ( highest - lowest ) * 100 else 50.0
}
2026-01-22 16:21:18 +09:00
private fun calculateChange ( list : List < CandleData > ) : Double {
val start = list . first ( ) . stck _oprc . toDouble ( )
val end = list . last ( ) . stck _prpr . toDouble ( )
return if ( start != 0.0 ) ( ( end - start ) / start ) * 100 else 0.0
}
private fun calculateRSI ( list : List < CandleData > ) : Double {
if ( list . size < 2 ) return 50.0
var gains = 0.0
var losses = 0.0
for ( i in 1 until list . size ) {
val diff = list [ i ] . stck _prpr . toDouble ( ) - list [ i - 1 ] . stck _prpr . toDouble ( )
if ( diff > 0 ) gains += diff else losses -= diff
}
return if ( gains + losses == 0.0 ) 50.0 else ( gains / ( gains + losses ) ) * 100
}
2026-01-23 17:05:09 +09:00
fun isDailyBullish ( ) : Boolean {
if ( daily . size < 20 ) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
val currentPrice = daily . last ( ) . stck _prpr . toDouble ( )
// 1. MA20 (한 달 생명선) 계산
val ma20 = daily . takeLast ( 20 ) . map { it . stck _prpr . toDouble ( ) } . average ( )
// 2. MA5 (단기 가속도) 계산
val ma5 = daily . takeLast ( 5 ) . map { it . stck _prpr . toDouble ( ) } . average ( )
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
val prevMa5 = daily . dropLast ( 1 ) . takeLast ( 5 ) . map { it . stck _prpr . toDouble ( ) } . average ( )
val isMa5Rising = ma5 > prevMa5
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
return currentPrice > ma20 && isMa5Rising
}
2026-01-22 16:21:18 +09:00
fun calculateOBV ( candles : List < CandleData > ) : Double {
var obv = 0.0
for ( i in 1 until candles . size ) {
val prevClose = candles [ i - 1 ] . stck _prpr . toDouble ( )
val currClose = candles [ i ] . stck _prpr . toDouble ( )
val currVol = candles [ i ] . cntg _vol . toDouble ( )
when {
currClose > prevClose -> obv += currVol
currClose < prevClose -> obv -= currVol
}
}
return obv
}
/ * *
* MFI ( Money Flow Index ) 계산 ( 기간 : 보통 14 일 )
* /
fun calculateMFI ( candles : List < CandleData > , period : Int = 14 ) : Double {
val subList = candles . takeLast ( period + 1 )
var posFlow = 0.0
var negFlow = 0.0
for ( i in 1 until subList . size ) {
val prevTypical = ( subList [ i - 1 ] . stck _hgpr . toDouble ( ) + subList [ i - 1 ] . stck _lwpr . toDouble ( ) + subList [ i - 1 ] . stck _prpr . toDouble ( ) ) / 3
val currTypical = ( subList [ i ] . stck _hgpr . toDouble ( ) + subList [ i ] . stck _lwpr . toDouble ( ) + subList [ i ] . stck _prpr . toDouble ( ) ) / 3
val moneyFlow = currTypical * subList [ i ] . cntg _vol . toDouble ( )
if ( currTypical > prevTypical ) posFlow += moneyFlow
else if ( currTypical < prevTypical ) negFlow += moneyFlow
}
return if ( negFlow == 0.0 ) 100.0 else 100 - ( 100 / ( 1 + ( posFlow / negFlow ) ) )
}
private fun calculateADLine ( candles : List < CandleData > ) : Double {
var ad = 0.0
candles . forEach {
val high = it . stck _hgpr . toDouble ( ) ; val low = it . stck _lwpr . toDouble ( ) ; val close = it . stck _prpr . toDouble ( )
val mfv = if ( high != low ) ( ( close - low ) - ( high - close ) ) / ( high - low ) else 0.0
ad += mfv * it . cntg _vol . toDouble ( )
}
return ad
}
2026-01-22 16:26:29 +09:00
fun clear ( ) {
monthly = emptyList ( )
weekly = emptyList ( )
daily = emptyList ( )
min30 = emptyList ( )
}
2026-01-22 17:56:31 +09:00
}
class ScalpingAnalyzer {
companion object {
private const val SMA _SHORT = 10
private const val SMA _LONG = 20
private const val RSI _WINDOW = 14
private const val VOL _WINDOW = 20
private const val VOL _SURGE _THRESHOLD = 1.5
private const val RSI _THRESHOLD = 50.0
private const val BB _LOWER _POS = 0.2
private const val BB _UPPER _POS = 0.8
private const val ATR _WINDOW = 14
2026-02-04 14:52:09 +09:00
private const val DEFAULT _SL _PCT = - 1.5
private const val DEFAULT _TP _PCT = 1.5
2026-01-22 17:56:31 +09:00
private const val HIGH _SCORE _THRESHOLD = 80
}
fun computeRSI ( closes : List < Double > , window : Int = RSI _WINDOW ) : List < Double > {
val rsi = mutableListOf < Double > ( )
if ( closes . size < window + 1 ) return rsi
for ( i in window until closes . size ) {
val gains = mutableListOf < Double > ( )
val losses = mutableListOf < Double > ( )
for ( j in ( i - window + 1 ) until i + 1 ) {
val delta = closes [ j ] - closes [ j - 1 ]
if ( delta > 0 ) gains . add ( delta ) else losses . add ( abs ( delta ) )
}
val avgGain = gains . average ( )
val avgLoss = losses . average ( )
val rs = if ( avgLoss > 0 ) avgGain / avgLoss else Double . POSITIVE _INFINITY
rsi . add ( 100.0 - ( 100.0 / ( 1.0 + rs ) ) )
}
return rsi
}
fun bollingerBands ( closes : List < Double > , window : Int = SMA _LONG ) : Triple < List < Double > , List < Double > , List < Double > > {
val sma = mutableListOf < Double > ( )
val upper = mutableListOf < Double > ( )
val lower = mutableListOf < Double > ( )
for ( i in window - 1 until closes . size ) {
val slice = closes . subList ( i - window + 1 , i + 1 )
val mean = slice . average ( )
val std = sqrt ( slice . map { ( it - mean ) . pow ( 2.0 ) } . average ( ) ) * 2.0
sma . add ( mean )
upper . add ( mean + std )
lower . add ( mean - std )
}
return Triple ( upper , sma , lower )
}
2026-01-23 17:05:09 +09:00
fun analyze ( candles : List < Candle > , isDailyBullish : Boolean ) : ScalpingSignalModel {
2026-01-22 17:56:31 +09:00
if ( candles . size < SMA _LONG ) throw IllegalArgumentException ( " 최소 20봉 필요 " )
val closes = candles . map { it . close }
val volumes = candles . map { it . volume }
// 지표 계산
val sma10 = simpleMovingAverage ( closes , SMA _SHORT )
val sma20 = simpleMovingAverage ( closes , SMA _LONG )
val rsiList = computeRSI ( closes )
val volAvg = simpleMovingAverage ( volumes , VOL _WINDOW )
val volRatioList = volumes . mapIndexed { i , v -> if ( i >= VOL _WINDOW ) v / volAvg [ i - VOL _WINDOW ] else 0.0 }
val ( bbUpper , bbMiddle , bbLower ) = bollingerBands ( closes )
val current = candles . last ( )
val idx = candles . size - 1
val currentClose = current . close
val sma10Now = if ( sma10 . size > 0 ) sma10 . last ( ) else 0.0
val sma20Now = if ( sma20 . size > 0 ) sma20 . last ( ) else 0.0
val rsiNow = if ( rsiList . isNotEmpty ( ) ) rsiList . last ( ) else 0.0
val volRatioNow = volRatioList . last ( )
val bbPos = if ( bbUpper . isNotEmpty ( ) && bbLower . isNotEmpty ( ) ) {
( currentClose - bbLower . last ( ) ) / ( bbUpper . last ( ) - bbLower . last ( ) )
} else 0.5
2026-01-23 17:05:09 +09:00
val nearHigh = candles . takeLast ( 6 ) . dropLast ( 1 ) . maxOf { it . high }
val isBreakout = currentClose > nearHigh
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
val bodySize = abs ( current . close - current . open )
val lowerShadow = minOf ( current . close , current . open ) - current . low
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
// 신호 조건 고도화
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
2026-01-22 17:56:31 +09:00
val rsiBull = rsiNow > RSI _THRESHOLD
val volSurge = volRatioNow > VOL _SURGE _THRESHOLD
val bbGood = bbPos > BB _LOWER _POS && bbPos < BB _UPPER _POS
2026-01-23 17:05:09 +09:00
val maBull = currentClose > sma10Now && sma10Now > sma20Now
2026-04-02 11:16:53 +09:00
// val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
val ma5Daily = if ( candles . size >= 5 ) candles . takeLast ( 5 ) . map { it . close . toDouble ( ) } . average ( ) else currentClose
val dailyDisparity = ( currentClose / ma5Daily ) * 100
// 과열 기준 정의
val isOverheated = dailyDisparity > 110.0 // 일봉 5일선 대비 10% 이상 이격 시 과열로 간주
// 매수 신호 조건에 과열 방지 추가
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout && !is Overheated
2026-01-23 17:05:09 +09:00
2026-01-22 17:56:31 +09:00
2026-01-23 17:05:09 +09:00
val score = ( if ( maBull ) 25 else 0 ) +
( if ( rsiBull ) 15 else 0 ) +
( if ( isBreakout ) 20 else 0 ) + // 돌파 에너지 가중치
( minOf ( ( volRatioNow - 1.0 ) * 20 , 20.0 ) ) . toInt ( ) +
( if ( bbGood ) 10 else 0 ) +
( if ( isDailyBullish ) 10 else 0 ) // 단타/장기 정렬 점수
2026-01-22 17:56:31 +09:00
// 위험도 (ATR proxy)
val returns = closes . mapIndexed { i , c -> if ( i > 0 ) ( c - closes [ i - 1 ] ) / closes [ i - 1 ] * 100 else 0.0 }
val atrProxy = if ( returns . size >= ATR _WINDOW ) {
returns . subList ( returns . size - ATR _WINDOW , returns . size ) . average ( )
} else 1.0
val riskLevel = when {
abs ( atrProxy ) < 1 -> " Low "
abs ( atrProxy ) < 2 -> " Medium "
else -> " High "
}
// 성공 확률 & SL/TP
val successProb = if ( buySignal ) 75.0 else 35.0 + ( score / 100.0 * 20 )
val slPrice = currentClose * ( 1 + DEFAULT _SL _PCT / 100 )
val tpPrice = currentClose * ( 1 + DEFAULT _TP _PCT / 100 )
val rrRatio = abs ( DEFAULT _TP _PCT / DEFAULT _SL _PCT )
2026-01-23 17:05:09 +09:00
2026-01-22 17:56:31 +09:00
return ScalpingSignalModel (
currentPrice = currentClose ,
buySignal = buySignal ,
compositeScore = minOf ( score . toInt ( ) , 100 ) ,
successProbPct = successProb ,
riskLevel = riskLevel ,
rsi = rsiNow ,
volRatio = volRatioNow ,
suggestedSlPrice = slPrice ,
suggestedTpPrice = tpPrice ,
riskRewardRatio = rrRatio
)
}
private fun simpleMovingAverage ( values : List < Double > , window : Int ) : List < Double > {
val sma = mutableListOf < Double > ( )
for ( i in window - 1 until values . size ) {
val slice = values . subList ( i - window + 1 , i + 1 )
sma . add ( slice . average ( ) )
}
return sma
}
}
data class Candle (
val timestamp : Long ,
val open : Double ,
val high : Double ,
val low : Double ,
val close : Double ,
val volume : Double
)
data class ScalpingSignalModel (
val currentPrice : Double ,
val buySignal : Boolean ,
val compositeScore : Int , // 0-100: 종합 매수 추천도 (80+ 강매수)
val successProbPct : Double , // 성공 확률 추정 %
val riskLevel : String , // "Low", "Medium", "High"
val rsi : Double ,
val volRatio : Double ,
val suggestedSlPrice : Double , // 손절 가격
val suggestedTpPrice : Double , // 익절 가격
val riskRewardRatio : Double
)
fun CandleData . toScalpingCandle ( ) : Candle {
// 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합
val dateTimeStr = " ${this.stck_bsop_date} ${this.stck_cntg_hour} "
val formatter = DateTimeFormatter . ofPattern ( " yyyyMMddHHmmss " )
// 2. 타임스탬프(Epoch Milliseconds) 계산
val timestamp = try {
val ldt = LocalDateTime . parse ( dateTimeStr , formatter )
ldt . atZone ( ZoneId . systemDefault ( ) ) . toInstant ( ) . toEpochMilli ( )
} catch ( e : Exception ) {
// 시간 파싱 실패 시 현재 시스템 시간 사용
System . currentTimeMillis ( )
}
// 3. String 필드들을 Double로 변환하여 Candle 객체 생성
return Candle (
timestamp = timestamp ,
open = this . stck _oprc . toDoubleOrNull ( ) ?: 0.0 ,
high = this . stck _hgpr . toDoubleOrNull ( ) ?: 0.0 ,
low = this . stck _lwpr . toDoubleOrNull ( ) ?: 0.0 ,
close = this . stck _prpr . toDoubleOrNull ( ) ?: 0.0 , // stck_prpr가 종가 역할
volume = this . cntg _vol . toDoubleOrNull ( ) ?: 0.0
)
}
/ * *
* 리스트 전체를 변환하는 유틸리티
* /
fun List < CandleData > . toScalpingList ( ) : List < Candle > {
return this . map { it . toScalpingCandle ( ) }
}
2026-04-02 14:05:14 +09:00
enum class InvestmentGrade (
val displayName : String ,
val description : String ,
val shortWeight : Double = 0.0 ,
val midWeight : Double = 0.0 ,
val longWeight : Double = 0.0 ,
val profitGuide : ConfigIndex ,
val buyGuide : ConfigIndex ,
val allocationRate : ConfigIndex ,
) {
LEVEL _5 _STRONG _RECOMMEND (
displayName = " 최상급 추천 " ,
description = " 단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천 " ,
shortWeight = 1.0 ,
midWeight = 1.0 ,
longWeight = 1.0 ,
profitGuide = ConfigIndex . GRADE _5 _PROFIT ,
buyGuide = ConfigIndex . GRADE _5 _BUY ,
allocationRate = ConfigIndex . GRADE _5 _ALLOCATIONRATE ,
) ,
LEVEL _4 _BALANCED _RECOMMEND (
displayName = " 균형 추천 " ,
description = " 중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천 " ,
shortWeight = 0.8 ,
midWeight = 1.0 ,
longWeight = 1.0 ,
profitGuide = ConfigIndex . GRADE _4 _PROFIT ,
buyGuide = ConfigIndex . GRADE _4 _BUY ,
allocationRate = ConfigIndex . GRADE _4 _ALLOCATIONRATE ,
) ,
LEVEL _3 _CAUTIOUS _RECOMMEND (
displayName = " 보수적 추천 " ,
description = " 중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함 " ,
shortWeight = 0.6 ,
midWeight = 1.0 ,
longWeight = 1.0 ,
profitGuide = ConfigIndex . GRADE _3 _PROFIT ,
buyGuide = ConfigIndex . GRADE _3 _BUY ,
allocationRate = ConfigIndex . GRADE _3 _ALLOCATIONRATE ,
) ,
LEVEL _2 _HIGH _RISK (
displayName = " 고위험 추천 " ,
description = " 단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자 " ,
shortWeight = 1.0 ,
midWeight = 0.4 ,
longWeight = 0.4 ,
profitGuide = ConfigIndex . GRADE _2 _PROFIT ,
buyGuide = ConfigIndex . GRADE _2 _BUY ,
allocationRate = ConfigIndex . GRADE _2 _ALLOCATIONRATE ,
) ,
LEVEL _1 _SPECULATIVE (
displayName = " 순수 공격적 선택 " ,
description = " 단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자 " ,
shortWeight = 1.0 ,
midWeight = 0.2 ,
longWeight = 0.2 ,
profitGuide = ConfigIndex . GRADE _1 _PROFIT ,
buyGuide = ConfigIndex . GRADE _1 _BUY ,
allocationRate = ConfigIndex . GRADE _1 _ALLOCATIONRATE ,
)
}