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

1224 lines
59 KiB
Kotlin
Raw Normal View History

2026-01-22 16:21:18 +09:00
package service
2026-03-13 16:37:53 +09:00
import AutoTradeItem
2026-03-17 10:50:13 +09:00
import network.TradingDecision
2026-03-13 17:34:48 +09:00
import TradingLogStore
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
import model.InvestmentGrade
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-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-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-13 16:37:53 +09:00
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
if (isSuccess && completeTradingDecision != null) {
// 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
TradingLogStore.addLog(completeTradingDecision)
println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}")
if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) {
var basePrice = completeTradingDecision.currentPrice
var stockCode = completeTradingDecision.stockCode
println("basePrice $basePrice")
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX)
val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
fun resultCheck(completeTradingDecision :TradingDecision) {
val weights = mapOf(
"short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
"profit" to 0.4,
"safe" to 0.4 // 중장기 점수 비중 강화
)
val totalScore =
((completeTradingDecision.shortPossible() + append) * weights["short"]!!) +
((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) +
((completeTradingDecision.safePossible() + append) * weights["safe"]!!)
if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
var investmentGrade : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
println("""
사명 : ${completeTradingDecision.corpName}
신뢰도 : ${completeTradingDecision.confidence + append}
단기성 : ${completeTradingDecision.shortPossible() + append}
수익성 : ${completeTradingDecision.profitPossible()+ append}
안전성 : ${completeTradingDecision.safePossible()+ append}
${investmentGrade.displayName} : ${investmentGrade.description}
총점 : ${totalScore}
""".trimIndent())
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val gradeRate = (1.0 - (investmentGrade.ordinal * 0.1))
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,
)
} else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) {
2026-03-13 17:34:48 +09:00
addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
2026-03-16 17:07:25 +09:00
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어[$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)}) 미달")
}
}
when (completeTradingDecision?.decision) {
"BUY" -> {
append = buyWeight
TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}")
resultCheck(completeTradingDecision)
}
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
"HOLD" -> {
append = 0.0
TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}")
resultCheck(completeTradingDecision)
}
else -> {
append = 0.0
println("[$stockCode] ${completeTradingDecision?.decision} resultCheck: ${completeTradingDecision?.reason}")
}
}
}
}
}
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")
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
}
}
}
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-13 16:37:53 +09:00
runDiscoveryLoop(KisTradeService, globalCallback)
2026-02-06 17:53:17 +09:00
}
2026-02-03 18:07:18 +09:00
2026-02-19 15:47:31 +09:00
2026-02-19 16:20:15 +09:00
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
2026-02-19 15:47:31 +09:00
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
2026-02-19 16:20:15 +09:00
println("resumePendingSellOrders")
2026-02-24 13:14:11 +09:00
balance.holdings.forEach { holding ->
2026-03-13 16:37:53 +09:00
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
2026-02-24 13:14:11 +09:00
println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
2026-02-19 15:47:31 +09:00
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
2026-02-24 13:14:11 +09:00
var targetPrice = holding.currentPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
2026-02-19 15:47:31 +09:00
tradeService.postOrder(
2026-02-24 13:14:11 +09:00
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
2026-02-19 15:47:31 +09:00
isBuy = false
).onSuccess { newOrderNo ->
// 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지
2026-02-24 13:14:11 +09:00
// DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo)
println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
2026-02-19 15:47:31 +09:00
}.onFailure {
2026-02-24 13:14:11 +09:00
println("❌ [재주문 실패] ${holding.name}: ${it.message}")
2026-02-19 15:47:31 +09:00
}
} else {
2026-03-13 16:37:53 +09:00
2026-02-19 15:47:31 +09:00
}
delay(200) // API 호출 부하 방지
}
}
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()) {
LlamaServerManager.startServer(binPath, config.modelPath,port = 8080)
}
if (config.embedModelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.embedModelPath, port = 8081)
}
KisWebSocketManager.connect()
isSystemReadyToday = true
} else {
println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.")
}
}
} catch (e: Exception) {}
}
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
var currentTimeMillis = System.currentTimeMillis()
var waitTime = 0.2
2026-02-06 17:53:17 +09:00
private fun runDiscoveryLoop(tradeService: KisTradeService, 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 {
//장중
now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> {
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-02-05 15:37:11 +09:00
2026-03-13 10:41:10 +09:00
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (now.isAfter(LocalTime.of(15, 20))) {
executeClosingLiquidation(tradeService)
return@withTimeout
}
2026-02-05 15:37:11 +09:00
2026-03-13 10:41:10 +09:00
val balance = tradeService.fetchIntegratedBalance().getOrNull()
balance?.let { resumePendingSellOrders(tradeService, it) }
val myCash = balance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
2026-03-16 17:07:25 +09:00
val myHoldings = balance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet()
2026-03-13 10:41:10 +09:00
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
if (remainingCandidates.isEmpty()) {
2026-03-16 17:07:25 +09:00
val stocks = StockUniverseLoader.loadUniverse()
println("✅ 총 ${stocks.size}개의 종목을 로드했습니다.")
stocks.forEach { (code, name) ->
// println("📌 로드됨: [$code] $name")
addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name))
}
2026-03-13 10:41:10 +09:00
val candidates: MutableList<RankingStock> = fetchCandidates(tradeService).apply {
println("후보군 총 개수 : $size")
}.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.asReversed())
}
reanalysisList.clear()
2026-03-16 17:07:25 +09:00
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 }.shuffled())
2026-03-13 10:41:10 +09:00
} else {
println("미확인 데이터 ${remainingCandidates.size}")
2026-03-16 17:07:25 +09:00
// remainingCandidates.removeIf { it.code in myHoldings || it.code in pendingStocks || it.code in executionCache.values.map { it.code } || it.code in failList}
2026-02-20 15:21:38 +09:00
}
2026-02-13 13:49:40 +09:00
2026-03-16 17:07:25 +09:00
2026-03-13 10:41:10 +09:00
var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
totalCount--
val stock = iterator.next()
try {
processSingleStock(stock, myCash, tradeService, callback)
} catch (e: Exception) {
println("❌ 처리 중 오류 발생 (건너뜀): ${stock.name}")
} finally {
iterator.remove()
}
println("남은 후보군 개수 : ${totalCount}")
delay(100)
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
2026-02-12 15:31:34 +09:00
}
2026-02-10 15:08:52 +09:00
}
2026-02-12 15:31:34 +09:00
2026-03-13 10:41:10 +09:00
//장외
now.isAfter(LocalTime.of(18, 0)) || now.isBefore(LocalTime.of(8, 50)) -> {
when {
(now.hour == 0 && now.minute == 0 && (isSystemReadyToday || isSystemCleanedUpToday)) -> {
waitTime = 10.0
isSystemReadyToday = false
isSystemCleanedUpToday = false
}
(now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> {
waitTime = 3.0
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
KisWebSocketManager.disconnect()
tryRefreshToken()
}
}
(now.isAfter(LocalTime.of(18, 0))) -> {
try {
waitTime = 5.0
println("current SystemCleanedUpToday is $isSystemCleanedUpToday")
if (!isSystemCleanedUpToday) {
println("🌙 [System] 업무 종료 및 자원 정리 시작...")
SystemSleepPreventer.sleepDisplay() // 모니터 끄기
KisWebSocketManager.disconnect()
2026-03-13 16:46:33 +09:00
BrowserManager.closeIfIdle(0) // 즉시 닫기
2026-03-13 10:41:10 +09:00
if (LlamaServerManager.stopAll()) {
isSystemCleanedUpToday = true
}
2026-03-13 16:46:33 +09:00
2026-03-13 10:41:10 +09:00
}
println("✅ [System] 오늘의 모든 정리가 완료되었습니다.")
} catch (e: Exception) {
}
}
2026-02-12 15:31:34 +09:00
2026-03-13 10:41:10 +09:00
(now.isAfter(LocalTime.of(18, 15)) && now.minute % 15 == 0) -> {
try {
waitTime = 5.0
SystemSleepPreventer.sleepDisplay() // 모니터 끄기
} catch (e: Exception) {
}
}
else -> {
waitTime = 5.0
}
2026-02-09 15:32:31 +09:00
}
2026-02-03 18:07:18 +09:00
}
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-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-02-04 14:52:09 +09:00
2026-02-10 15:08:52 +09:00
2026-02-13 13:49:40 +09:00
RagService.processStock(currentPrice,analyzer, stock.name, stock.code) { decision, isSuccess ->
2026-02-06 17:53:17 +09:00
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-13 16:46:33 +09:00
BrowserManager.closeIfIdle(0) // 즉시 닫기
2026-02-06 17:53:17 +09:00
while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
2026-02-13 13:49:40 +09:00
println("💤 대기 모드 상태 확인...")
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-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 (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
2026-03-13 16:37:53 +09:00
startAutoDiscoveryLoop()
2026-02-06 17:53:17 +09:00
} else {
2026-01-22 16:21:18 +09:00
2026-02-06 17:53:17 +09:00
}
2026-01-22 16:21:18 +09:00
}
2026-02-06 17:53:17 +09:00
2026-01-22 16:21:18 +09:00
}
2026-02-10 15:08:52 +09:00
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-03-04 16:39:21 +09:00
return isDebtSafe && isLiquiditySafe && isNotDeficit
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
}
/**
* 종합 상태 반환 (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 """
ultraShort $ultraShort
shortTerm $shortTerm
midTerm $midTerm
longTerm $longTerm
""".trimIndent()
}
}
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-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-02-04 14:52:09 +09:00
val ultra = (calculateMFI(min30, 14) * 0.4 +
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-02-04 14:52:09 +09:00
val short = (calculateRSI(daily) * 0.3 +
(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-02-04 14:52:09 +09:00
val mid = (if(calculateChange(weekly) > 0) 40 else 10) +
2026-01-22 17:56:31 +09:00
(financialScore * 0.6).toInt()
// 4. 장기 (월봉 + 섹터/기업 펀더멘털)
2026-02-04 14:52:09 +09:00
val long = (if(calculateChange(monthly) > 0) 50 else 0) +
2026-01-22 17:56:31 +09:00
(financialScore * 0.5).toInt()
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
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
// val buySignal = maBull && rsiBull && volSurge && bbGood
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() }
}