atrade/src/main/kotlin/service/AutoTradingManager.kt
2026-06-19 14:40:46 +09:00

1381 lines
72 KiB
Kotlin

package service
import AutoTradeItem
import Defines.BLACKLISTEDSTOCKCODES
import Defines.EMBEDDING_PORT
import Defines.LLM_PORT
import TradingLogStore
import TradingLogStore.noticeFilter
import analyzer.AdvancedTradeAssistant
import analyzer.TechnicalAnalyzer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import getLlamaBinPath
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import model.ConfigIndex
import model.ExecutionData
import model.KisSession
import model.RankingStock
import model.RankingType
import model.TradingDecision
import model.UnifiedBalance
import model.UnifiedStockHolding
import network.DartCodeManager
import network.KisAuthService
import network.KisTradeService
import network.KisWebSocketManager
import network.RagService
import network.RagService.isSafetyBeltStockCodes
import network.StockUniverseLoader
import report.TradingReportManager
import util.MarketUtil
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.util.concurrent.atomic.AtomicLong
import kotlin.collections.List
import kotlin.collections.filter
import kotlin.math.abs
import kotlin.math.max
// service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
object AutoTradingManager {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var discoveryJob: Job? = null
// 모니터링을 위한 상태 변수
private val lastTickTime = AtomicLong(System.currentTimeMillis())
private var watchdogJob: Job? = null
var CYCLE_TIMEOUT = KisSession.tradeConfig.CYCLE_TIMEOUT
var WATCHDOG_CHECK_INTERVAL = KisSession.tradeConfig.WATCHDOG_CHECK_INTERVAL
var STUCK_THRESHOLD = KisSession.tradeConfig.STUCK_THRESHOLD
var ONE_STOCK_ALYSIS_TIME = KisSession.tradeConfig.ONE_STOCK_ALYSIS_TIME
fun isRunning(): Boolean = discoveryJob?.isActive == true
private var remainingCandidates = mutableListOf<RankingStock>()
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>()
var shouldShowFullWindow by mutableStateOf(false)
var llmAnalyser by mutableStateOf(false)
var llmNews by mutableStateOf(false)
var tradeToken by mutableStateOf(false)
var webSocketConnect by mutableStateOf(false)
var testFlag = false
fun startBackgroundScheduler() {
scope.launch {
while (isActive) {
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
var checkTime = 60_000 * 3L
val isTradingDay = MarketUtil.canTradeToday()
if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) {
shouldShowFullWindow = true
// SystemSleepPreventer.wakeDisplay()
} else if ((now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0)))) {
// SystemSleepPreventer.sleepDisplay()
}
// if (!isTradingDay) {
// checkTime = 60_000 * 30L
// }
delay(checkTime) // 1분마다 체크
}
}
}
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
val seoulZone = ZoneId.of("Asia/Seoul")
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (KisSession.isAvailBuyTime(now) && isSuccess && completeTradingDecision != null) {
val decision = completeTradingDecision
println("${decision.stockName} ${decision.decision}")
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
if (decision.decision == "BUY") {
var maxRealisticProfitRate = 0.0
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
decision.analyzer?.let { a ->
val volatility = a?.calculateVolatilityForecast(a.daily, 20)
volatility?.let {
maxRealisticProfitRate = ((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0
}
}
// 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준)
// 2. 시스템 기본 설정 수익률과 비교
val baseProfitRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(grade.profitGuide)
// 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤
val finalProfitRate = if (maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate) {
max(maxRealisticProfitRate ,0.05)
} else {
baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행
}
// 2. 최종 매수 실행
val gradeRate = KisSession.config.getValues(grade.allocationRate)
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
decision.maxRealisticProfitRate = maxRealisticProfitRate
TradingLogStore.addLog(decision,"BUY",decision.summary(KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide)))
var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings()?.any { it.code.equals(decision.stockCode) && it.quantity.toInt() > 2 && it.availOrderCount.toInt() > 0} ?: false
if (hasCodes == true) {
TradingLogStore.addNotice(decision.stockName,decision.stockCode,"물타기 시도 1주 매수")
}
val calculatedQty = if(hasCodes == true) KisSession.tradeConfig.lowerAverageStockCount else (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
excuteTrade(
decision = decision,
orderQty = calculatedQty.toString(),
profitRate1 = finalProfitRate,
investmentGrade = grade,
hasCode = hasCodes == true
)
} else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
}
} else {
}
}
fun getInvestmentGrade(
ts: TradingDecision,
totalScore: Double,
confidence: Double,
finScore100: Double // 💡 [수정1] 컴파일 에러 방지용 파라미터 추가
): InvestmentGrade {
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
val minConfidence = minScore
if (totalScore < (minScore * 0.8) || confidence < minConfidence) {
return InvestmentGrade.LEVEL_0_SPECULATIVE
}
val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0
val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0
val isOverheated = ts.analyzer?.isOverheatedStock() ?: true
// 1. 기본 등급 산정
var rawGrade = when {
midLongAvg >= 70.0 -> {
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
}
midLongAvg >= 60.0 -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
else InvestmentGrade.LEVEL_2_HIGH_RISK
}
else -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
else InvestmentGrade.LEVEL_1_SPECULATIVE
}
}
// 💡 [수정2] 누락되었던 우량주 눌림목 프리미엄 & 잡주 투매 회피 로직 추가
val isHealthy = finScore100 >= 70.0
val isPullback = midLongAvg >= 75.0 && shortAvg <= 45.0
if (isHealthy && isPullback) {
rawGrade = when (rawGrade) {
InvestmentGrade.LEVEL_1_SPECULATIVE,
InvestmentGrade.LEVEL_2_HIGH_RISK -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else -> rawGrade
}
} else if (!isHealthy && isPullback) {
rawGrade = when (rawGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND,
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_1_SPECULATIVE
InvestmentGrade.LEVEL_2_HIGH_RISK,
InvestmentGrade.LEVEL_1_SPECULATIVE -> InvestmentGrade.LEVEL_0_SPECULATIVE
else -> InvestmentGrade.LEVEL_0_SPECULATIVE
}
}
return if (isOverheated) {
when (rawGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_2_HIGH_RISK
else -> InvestmentGrade.LEVEL_1_SPECULATIVE
}
} else {
rawGrade
}
}
fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK, hasCode: Boolean) {
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())
val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt()
if (!canAddNewPosition(maxStocks)) {
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
TradingLogStore.addWatchLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
} else if (KisSession.isAvailBuyTime(LocalTime.now())){
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice hasStocks : ${stockCode.contains(stockCode)}" )
var realOrderQty = orderQty
KisTradeService.postOrder(stockCode, realOrderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo ->
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
TradingLogStore.addLog(
decision,
"BUY",
"[${investmentGrade.displayName}] 주문 성공: $realOrderNo"
)
val sRate = -1.5
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
val effectiveProfitRate =
(profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
try {
var oldTarget = currentBalance?.getHoldings()?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) }
if (KisSession.tradeConfig.lowerAveragePrice && hasCode && oldTarget != null) {
var avgPrive = oldTarget.avgPrice.toDouble()
var qty = oldTarget.quantity.toDouble()
basePrice = avgPrive * 1.5//((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt()))
println("물타기 ${avgPrive}, ${qty} ${basePrice}")
}
} catch (e:Exception) {e.printStackTrace()}
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
)
)
TradingReportManager.recordTradeDecision(
orderNo = realOrderNo,
stockCode = stockCode,
stockName = stockName,
isBuy = true,
orderQty = inputQty,
reason = decision.reason ?: "", // AI 이유
decision = decision // AI 객체 통째로 전달
)
if (!hasCode) {
syncAndExecute(realOrderNo)
}
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
TradingLogStore.addLog(
decision,
"BUY",
"[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${
String.format(
"%.4f",
effectiveProfitRate
)
}%): $realOrderNo"
)
}
.onFailure {
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
if (it.message?.contains("주문가능금액을 초과") == true) {
AutoTradingManager.addToReanalysis(
RankingStock(
mksc_shrn_iscd = stockCode,
hts_kor_isnm = stockName
)
)
TradingLogStore.addWatchLog(
decision,
"WATCH",
"${it.message ?: " 매수 실패"} => 재분석 대기열에 추가"
)
} else {
TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패")
}
}
} else {
val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 매수시간 종료 후 모든 매수 취소")
KisTradeService.cancelOrder(
order.ord_no, // 원주문번호
order.pdno
)
}
}
}
}
}
var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy ->
scope.launch {
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
println("exec >> ${exec}")
executionCache[orderNo] = exec
syncAndExecute(orderNo)
}
}
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. 진짜 사온 가격 (실제 매수 체결가)
var actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity)
var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings()?.any { it.code.equals(dbItem.code) && it.quantity.toInt() > 2 && dbItem.quantity == KisSession.tradeConfig.lowerAverageStockCount } ?: false
if (hasCodes) {
actualBuyPrice = actualBuyPrice * 1.1
}
val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05
val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}")
KisTradeService.postOrder(
stockCode = dbItem.code,
qty = dbItem.quantity.toString(),
price = finalTargetPrice.toLong().toString(),
isBuy = false
).onSuccess { newSellOrderNo ->
// 💡 [매도 주문 기록] 이제 팔기 시작했다는 의사결정을 리포트에 남깁니다.
TradingReportManager.recordTradeDecision(
orderNo = newSellOrderNo,
stockCode = dbItem.code,
stockName = dbItem.name,
isBuy = false,
orderQty = dbItem.quantity,
reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)}% 도달을 위한 익절 주문",
holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다!
decision = null
)
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
executionCache.remove(orderNo)
}
} else if (dbItem.status == TradeStatus.SELLING) {
// ✅ 2. 매도 완료 시점 (실제 매도 체결가)
val actualSellPrice = execData.price.toDoubleOrNull() ?: 0.0
val actualSellQty = execData.qty.toIntOrNull() ?: dbItem.quantity
// 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록
TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty)
println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}")
TradingLogStore.addSellLog(dbItem.name,actualSellPrice.toString(),"SELL","매도 완료")
TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
}
}
} finally {
processingIds.remove(orderNo)
}
}
/**
* 자동 발굴 루프 시작 및 Watchdog 실행
*/
fun startAutoDiscoveryLoop(doStart : Boolean = false) {
if (isRunning()) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
watchdogJob?.cancel()
watchdogJob = scope.launch {
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (doStart && activeTrades.isNotEmpty() && !KisSession.isAvailBuyTime(now)) {
executeClosingLiquidation(activeTrades)
}
while (isActive) {
delay(WATCHDOG_CHECK_INTERVAL)
val now = System.currentTimeMillis()
if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) {
println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.")
restartLoop()
}
}
}
// 2. 메인 루프 실행
runDiscoveryLoop(globalCallback)
}
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
} else {
val targetProfitLimit = if (holding.isTodayEntry) {
// 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도)
KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX)
} else {
// 오래 보유한 종목: 기존 설정값 준수 (예: 3.0% 등)
KisSession.config.SELL_PROFIT
}
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > targetProfitLimit) {
var targetPrice = holding.currentPrice.toDouble()
TradingLogStore.addAfterMarketLog(
holding.name,
holding.code,
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
)
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
orderDivision = if (marketCode.equals("Y")) "07" else "",
marketCode = if (marketCode.equals("Y")) "KRX" else "NXT"
).onSuccess { newOrderNo ->
println("✅ [${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주문 완료] ${holding.name}: $newOrderNo")
TradingLogStore.addSellLog(
"${holding.name}[${holding.code}]",
targetPrice.toString(),
"SELL",
"🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료"
)
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = newOrderNo,
code = holding.code,
name = holding.name,
quantity = holding.quantity.toInt(),
profitRate = 0.0,
stopLossRate = 0.0,
targetPrice = targetPrice.toDouble(),
stopLossPrice = 0.0,
status = "SELLING",
isDomestic = true
))
syncAndExecute(newOrderNo)
}.onFailure {
TradingLogStore.addSellLog(
"${holding.name}[${holding.code}]",
targetPrice.toString(),
"SELL",
"🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 실패[${it.message}] "
)
}
} else {
if ("Y".equals(marketCode)) {
if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0
&& holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)
&& holding.profitRate.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE)
&& holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) {
println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}")
val profit = holding.profitRate.toDouble()
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함."
)
}
analyzeDeepLossHoldingsAfterMarket(holding)
}
}
delay(300) // API 호출 부하 방지
}
}
}
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
val now = LocalTime.now()
val currentMinute = now.minute
if (now.isBefore(H15M30) && now.isAfter(H08M45)) {
println("resumePendingSellOrders")
balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
holding.name,
holding.code,
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
)
} else {
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
var targetPrice = holding.currentPrice.toDouble()
val now = LocalTime.now()
val currentMinute = now.minute
var isBefore930 = false
if (now.hour == 9 && currentMinute < 30) {
targetPrice = targetPrice
isBefore930 = true
} 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] 주문"} 완료"
)
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = newOrderNo,
code = holding.code,
name = holding.name,
quantity = holding.quantity.toInt(),
profitRate = 0.0,
stopLossRate = 0.0,
targetPrice = targetPrice.toDouble(),
stopLossPrice = 0.0,
status = "SELLING",
isDomestic = true
))
syncAndExecute(newOrderNo)
}.onFailure {
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"🎊 보유 주식 매도 주문 실패[${it.message}] "
)
}
} else {
var errMsg = ""
var isSuccess = false
if (KisSession.tradeConfig.autoSellOrder
&& holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.tradeConfig.autoSellOrderMin
&& holding.profitRate.toDouble() >= KisSession.tradeConfig.autoSellOrderMax
&& holding.avgPrice.toDouble() > holding.currentPrice.toDouble()) {
var targetPrice = holding.avgPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend)
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
).onSuccess { newOrderNo ->
println("✅ [보유 주식 손절 처리] ${holding.name} 매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문")
isSuccess = true
}.onFailure { err->
println("✅ [보유 주식 손절 처리] ${holding.name} 실패 ${targetPrice} ${err.message}")
errMsg = err.message.toString()
}
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문 ${if (isSuccess) "성공" else "실패[${errMsg}]"}"
)
} else if (KisSession.config.stop_Loss
&& holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)
&& holding.profitRate.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE)
&& holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) {
println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}")
val profit = holding.profitRate.toDouble()
var targetPrice = holding.currentPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend)
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
).onSuccess { newOrderNo ->
println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.")
}.onFailure { err->
println("✅ [보유 주식 손절 처리] 실패 ${err.message}")
}
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
)
}
analyzeDeepLossHoldingsAfterMarket(holding , true)
}
delay(200) // API 호출 부하 방지
}
}
}
}
private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding, isForce : Boolean = false) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
val now = LocalTime.now()
val currentMinute = now.minute
if ((holding.availOrderCount.toInt()
?: 0) > 0 && ((!isForce && (now.hour == 8 || now.hour == 16 || now.hour == 17)) || (isForce && (currentMinute % 5 == 0)))
) {
val profit = holding.profitRate.toDouble()
val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다)
if (profit <= lossThreshold) {
println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...")
val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull()
if (!dailyData.isNullOrEmpty()) {
val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
val currentPrice = holding.currentPrice.toDouble()
// 1. 볼린저 밴드 하단선 (통계적 바닥) 확인
val lowerBand = AdvancedTradeAssistant.calculateBollingerLowerBand(dailyData)
// 2. RSI 확인 (과매도 투매 상태인지)
val rsiDaily = analyzer.calculateRSI(dailyData)
// 3. 중기 추세 확인 (최근 20일 기준 10% 이상 하락했는지)
val isTrendBroken = analyzer.calculateChange(dailyData.takeLast(20)) < -10.0
var advice = ""
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) {
advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> $advice"
)
}
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
else if (isTrendBroken && currentPrice > lowerBand * 1.1) {
advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다."
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률 심각($profit%) -> $advice",
holding.quantity.toInt()
)
}
// 🟡 [관망] 어정쩡하게 물려있는 상태
else {
advice = "⏳ [관망 유지] 뚜렷한 반등 시그널(바닥)이나 치명적 투매 시그널이 없습니다. 조금 더 지켜봅니다."
TradingLogStore.addAnalyzer(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> $advice", false
)
}
}
} else {
// -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김
// TradingLogStore.addAnalyzer("보유주식[${holding.name}]", holding.code, "수익률 미달 대기중 (${profit}%)")
}
}
}
var isSystemReadyToday = false
var isSystemCleanedUpToday = false
private var lastRetryTime = 0L
val binPath = getLlamaBinPath()
fun canAddNewPosition( // 대표님의 시스템에 맞는 미체결 주문 객체 리스트
maxAllowedStocks: Int
): Boolean {
// 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환
return true //(currentBalance?.getHoldings()?.count { it.availOrderCount.toInt() > 0 } ?: 0) > maxAllowedStocks
}
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 = LLM_PORT)
}
if (config.embedModelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT)
}
KisWebSocketManager.connect()
isSystemReadyToday = true
shouldShowFullWindow = true
} else {
println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.")
}
}
} catch (e: Exception) {}
}
var onMarketClosed: (() -> Unit)? = null
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
var currentTimeMillis = System.currentTimeMillis()
var waitTime = 0.2
val H15M30 = LocalTime.of(15, 30)
val H08M45 = LocalTime.of(8, 45)
private fun runDiscoveryLoop(callback: TradingDecisionCallback) {
discoveryJob = scope.launch {
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
while (isActive) {
try {
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
when {
now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime()) -> {
prepareMarketOpen(now)
}
now.isBefore(KisSession.endTime()) && now.isAfter(KisSession.startTime()) -> {
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()}")
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
if (now.isAfter(KisSession.endBuyTime()) && activeTrades.isNotEmpty()) {
executeClosingLiquidation(activeTrades)
} else {
executeMarketLoop()
}
}
}
else ->{
waitTime = 3.0
}
}
} catch (e: TimeoutCancellationException) {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
} catch (e: Exception) {
println("⚠️ [Loop Error] ${e.message}")
delay(1000)
}
waitForNextCycle(waitTime)
}
}
}
suspend fun prepareMarketOpen(now : LocalTime) {
if (now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime())) {
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
onMarketClosed?.invoke()
RagService.clearDailyCache()
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0)
LlamaServerManager.stopAll() // AI 서버 완전 종료
TradingLogStore.clear()
isSystemReadyToday = false
shouldShowFullWindow = false
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if (now.isAfter(KisSession.startTime().minusMinutes(20)) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow) {
if (MarketUtil.canTradeToday()) {
shouldShowFullWindow = true
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true)
} else {
println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.")
delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여
}
}
}
var loadedTops = mutableListOf<Pair<String, String>>()
var defaultStockCount = 30
var currentBalance : UnifiedBalance? = null
private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond)
private val FETCH_INTERVAL = 2 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms)
suspend fun checkBalance() {
val currentTime = System.currentTimeMillis()
if (currentBalance == null || (currentTime - lastFetchTime) > FETCH_INTERVAL) {
KisTradeService.fetchIntegratedBalance().getOrNull()?.let {
currentBalance = it
lastFetchTime = currentTime // 호출 성공 시 현재 시간으로 갱신
println("잔고 동기화 완료")
} ?: run {
println("잔고 조회 실패 (네트워크 오류 등)")
}
} else {
// 30분이 지나지 않았다면 기존에 저장된 currentBalance를 그대로 사용
println("${(FETCH_INTERVAL / (1000 * 60))}분이 지나지 않아 기존 잔고 데이터 유지 (남은 시간: ${(FETCH_INTERVAL - (currentTime - lastFetchTime)) / 1000}초)")
}
if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
if (KisSession.tradeConfig.auto_cancel_pending_buy) { checkAndCancelPendingBuyOrders() }
}
suspend fun checkAndCancelPendingBuyOrders(
) {
// 1. 미체결 내역 조회
val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response ->
val currentTime = System.currentTimeMillis()
// 매수 주문('02')만 필터링
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
val orderTimeMillis = parseOrderTime(order.ord_tmd)
val elapsedMillis = currentTime - orderTimeMillis
if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time ) {
// 2. 현재가 조회 (가격을 비교하기 위해)
val currentPrice = KisTradeService.fetchCurrentPrice(order.pdno).getOrNull()?.stck_prpr?.toDouble() ?: 0.0
val orderedPrice = order.ord_unpr.toDoubleOrNull() ?: 0.0
// 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때)
val priceGap = Math.abs(currentPrice - orderedPrice) / orderedPrice
println("checkAndCancelPendingBuyOrders order $order ${elapsedMillis / 1000L}${priceGap}% 차이")
if (priceGap >= KisSession.tradeConfig.auto_cancel_pending_rate) {
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] ${order.prdt_name} (${order.pdno}) - 시간경과 및 가격괴리(${String.format("%.2f", priceGap)}%)로 취소 시도")
KisTradeService.cancelOrder(
order.ord_no, // 원주문번호
order.pdno
)
}
}
}
}
}
suspend fun cancelAllPendingSellOrders(
) {
// 1. 미체결 내역 조회
val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "01" }.forEach { order ->
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 정규장 시작전 모든 매도 주문 취소")
KisTradeService.cancelOrder(
order.ord_no, // 원주문번호
order.pdno
)
}
}
}
// 주문 시간 문자열을 Millis로 변환하는 유틸리티 (당일 주문 기준)
fun parseOrderTime(ordTmd: String): Long {
return try {
val now = java.time.LocalDateTime.now()
val hour = ordTmd.substring(0, 2).toInt()
val min = ordTmd.substring(2, 4).toInt()
val sec = ordTmd.substring(4, 6).toInt()
val orderDateTime = now.withHour(hour).withMinute(min).withSecond(sec)
orderDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
suspend fun executeMarketLoop() {
checkBalance()
var myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong()
myCash = max(myCash,KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong())
val myHoldings = currentBalance?.getHoldings()?.filter { !it.isTodayEntry }?.map { it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (remainingCandidates.isEmpty()) {
if (loadedTops.size < defaultStockCount) {
loadedTops.addAll(StockUniverseLoader.loadUniverse())
println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.")
}
loadedTops.shuffle()
val count = minOf(loadedTops.size, defaultStockCount)
for (i in 0 ..< count) {
loadedTops.removeFirst().let {
addToReanalysis(RankingStock(mksc_shrn_iscd = it.first, hts_kor_isnm = it.second))
}
}
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 < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (KisSession.tradeConfig.minusFilter * -1))
if (corpInfo?.cName.isNullOrEmpty()) {
false
} else if (it.code !in myHoldings &&
it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } &&
it.code !in failList &&
it.code !in isSafetyBeltStockCodes){
isOk
} else {
false
}
}
.filter { !it.name.contains("호스팩", true) }
.toMutableList()
if (reanalysisList.isNotEmpty()) {
candidates.addAll(reanalysisList)
}
reanalysisList.clear()
if (KisSession.tradeConfig.lowerAveragePrice) {
currentBalance?.getHoldings()?.map {
if(
it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount &&
it.profitRate.toDouble() < (abs(KisSession.tradeConfig.lowerAverageMaxRate) * -1) &&
it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1))
{
candidates.add(RankingStock(mksc_shrn_iscd = it.code, hts_kor_isnm = it.name))
println("물타기 대상 추가 ${it.name}[${it.code}]")
var oldTarget = it
if ( oldTarget != null) {
var avgPrive = oldTarget.avgPrice.toDouble()
var qty = oldTarget.quantity.toDouble()
var basePrice = ((avgPrive * qty) + it.currentPrice.toDouble()).div(qty!!.toInt() + 1)
println("물타기 ${avgPrive}, ${qty} ${basePrice}")
}
}
}
}
remainingCandidates.addAll(candidates.filter {
(if (KisSession.tradeConfig.lowerAveragePrice) { true } else {it.code !in myHoldings}) &&
it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } &&
it.code !in failList &&
it.code !in isSafetyBeltStockCodes
}.distinctBy { it.code })
remainingCandidates.shuffle()
} else {
println("미확인 데이터 ${remainingCandidates.size}")
}
var totalCount = remainingCandidates.size
println("후보군 조건 충족 총 개수 : ${totalCount}")
val iterator = remainingCandidates.iterator()
while (iterator.hasNext()) {
totalCount--
val stock = iterator.next()
if (KisSession.isAvailBuyTime(now)) {
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(500)
}
}
sellSchedule()
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
}
// private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장
private val executionCountMap = mutableMapOf<String, Int>()
suspend fun sellSchedule() {
if (KisSession.config.take_profit == false) return
val now = LocalTime.now()
val timeKey = String.format("%02d:%02d", now.hour, now.minute) // 예: "09:05"
val currentCount = executionCountMap.getOrDefault(timeKey, 0)
if (currentCount >= KisSession.tradeConfig.excuteCountOnMin) { return }
var isExecuted = false
val currentMinute = now.minute
if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) {
cancelAllPendingSellOrders()
isExecuted = true
} else if ( (now.isBefore(LocalTime.of(16,0)) && now.isAfter(KisSession.endBuyTime())) ) {
val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 정규장 후 모든 매수 취소")
KisTradeService.cancelOrder(
order.ord_no, // 원주문번호
order.pdno
)
}
}
isExecuted = true
} else if (now.hour == 9 && now.minute % KisSession.tradeConfig.excuteMinCheck == 0) {
TradingLogStore.addAnalyzer(
" - ",
" - ",
"⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.",
true
)
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
checkBalance()
isExecuted = true
} else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) ||
(now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 0)) {
TradingLogStore.addAnalyzer(
" - ",
" - ",
"⏰ [강제 스케줄 실행] 오후 ${now.hour}${currentMinute}분 - 보유주식 시간외 단일가 또는 대체마켓 체크를 시작합니다.",
true
)
var list = mutableListOf<String>("X")
if (now.hour != 8 && now.hour < 18) {
list.add("Y")
}
list.forEach { code ->
KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
sellingAfterMarketOnePrice(KisTradeService, it, code)
}
}
isExecuted = true
}
if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 }
if (now.hour >= 20) {
executionCountMap.clear()
noticeFilter.clear()
}
}
fun addToReanalysis(stock: RankingStock) {
val count = retryCountMap.getOrDefault(stock.code, 0)
if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지
retryCountMap[stock.code] = count + 1
reanalysisList.add(stock)
// println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
}
}
val failList = arrayListOf<String>()
private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) {
try {
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)
// 개별 종목 분석은 최대 2분으로 제한
withTimeout(ONE_STOCK_ALYSIS_TIME) {
val corpInfo = DartCodeManager.getCorpCode(stock.code)
if (corpInfo?.cName.isNullOrEmpty()) {
print("-> 기업명을 못찾아서 제외 | ")
return@withTimeout
}
if(currentBalance?.getHoldings()?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2} == true) {
println("물타기 대상 분석")
}
callback(TradingDecision().apply {
this.stockCode = stock.code
this.confidence = -1.0
this.stockName = stock.name
}, false)
val dailyData =
tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
val today = dailyData.lastOrNull() ?: null
if (today == null) {
failList.add(stock.code)
print("-> 금일 금액 조회 실패 | ")
return@withTimeout
}
val currentPrice = today.stck_prpr.toDouble()
if ((myCash > 10L && currentPrice > myCash) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> [${stock.name}] 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
return@withTimeout
}
// 🌟 [추가] 고도화된 사전 필터링 (검문소)
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?)
val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20)
val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0
// 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?)
val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData)
val isApproaching = tempAnalyzer.checkReboundApproaching(
candles = dailyData,
avgReboundTerm = dailyStats.avgReboundPeriod,
dropThreshold = dailyStats.avgDropRate * 0.8,
timeTolerance = dailyStats.timeTolerance
)
val isSteadyUptrend = tempAnalyzer.checkSteadyUptrend(dailyData)
// 🌟 [수정] 조건 통합 (OR 조건)
val isProfitable = expectedProfitRate >= 2.0 || dailyStats.avgReboundAmplitude >= 2.0
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= 10.0 && dailyStats.avgReboundPeriod >= 1.5) || isSteadyUptrend
if (!isProfitable || !isValidEntryTiming) {
print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isApproaching) | ")
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
}
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
if (!isSafetyBeltStockCodes.contains(stock.code)) {
val analyzer = coroutineScope {
val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
delay(20)
val weekly =
async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
delay(20)
val monthly =
async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) }
delay(20)
TechnicalAnalyzer().apply {
this.daily = dailyData
delay(50)
this.min30 = min30.await()
delay(50)
this.weekly = weekly.await()
delay(50)
this.monthly = monthly.await()
}
}
if (analyzer.isValid()) {
println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess ->
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
}
} else {
println("✅ [분석 실패] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
}
} else {
println("재무 안정성 부족 (캐시)")
}
println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})")
}
} catch (e: Exception) {
println("❌ [Stock Error] ${stock.name}: ${e.message}")
}
}
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
listOf(
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
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()) },
).awaitAll().flatten()
}
private fun restartLoop() {
discoveryJob?.cancel()
startAutoDiscoveryLoop()
}
private suspend fun waitForNextCycle(minutes: Double) {
println("💤 대기 모드 진입... $minutes")
val endWait = System.currentTimeMillis() + (minutes * 60 * 1000L)
try {
BrowserManager.closeIfIdle(0) // 즉시 닫기
} catch (e: Exception) {
}
while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
println("💤 대기 모드 상태 확인...$minutes")
delay(if(minutes > 3.0 ) 10000 else 1000)
}
}
private suspend fun executeClosingLiquidation(activeTrades: List<AutoTradeItem>) {
activeTrades.forEach { trade ->
try {
DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED)
println("📢 [마감 정리 체크] ${trade.name}")
} catch (e: Exception) {
println("⚠️ [마감 에러] ${trade.name}: ${e.message}")
}
delay(5)
}
}
fun stopDiscovery() {
discoveryJob?.cancel()
discoveryJob = null
println("🛑 [AutoTrading] 자동 발굴 중단됨")
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()
}
}
}
data class Candle(
val timestamp: Long,
val open: Double,
val high: Double,
val low: Double,
val close: Double,
val volume: Double
)
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,
),
LEVEL_0_SPECULATIVE(
displayName = "매수 금지 (관망)",
description = "최소 신뢰도(Confidence) 미달로 시스템 통과 실패",
shortWeight = 0.1,
midWeight = 0.1,
longWeight = 0.1,
profitGuide = ConfigIndex.GRADE_1_PROFIT, // 더미 데이터
buyGuide = ConfigIndex.GRADE_1_BUY,
allocationRate = ConfigIndex.GRADE_1_ALLOCATIONRATE,
)
}