1175 lines
59 KiB
Kotlin
1175 lines
59 KiB
Kotlin
package service
|
|
|
|
import AutoTradeItem
|
|
import Defines.AUTOSELL
|
|
import Defines.BLACKLISTEDSTOCKCODES
|
|
import Defines.EMBEDDING_PORT
|
|
import Defines.LLM_PORT
|
|
import TradingLogStore
|
|
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.StockUniverseLoader
|
|
import report.SnapshotType
|
|
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
|
|
|
|
// 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
|
|
|
|
private const val CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분
|
|
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
|
|
private const val STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
|
|
private const val ONE_STOCK_ALYSIS_TIME = 180000L
|
|
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 seoulZone = ZoneId.of("Asia/Seoul")
|
|
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
|
val nowDate = LocalDate.now(seoulZone)
|
|
var checkTime = 60_000 * 3L
|
|
val isTradingDay = nowDate.dayOfWeek.value in 1..5
|
|
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
|
|
|
|
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
|
|
if (decision.decision == "BUY") {
|
|
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
|
|
|
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
|
|
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
|
|
|
|
// 2. 최종 매수 실행
|
|
val gradeRate = KisSession.config.getValues(grade.allocationRate)
|
|
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
|
|
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
|
|
TradingLogStore.addLog(decision,"BUY",decision.summary())
|
|
excuteTrade(
|
|
decision = decision,
|
|
orderQty = calculatedQty.toString(),
|
|
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
|
|
investmentGrade = grade
|
|
)
|
|
} else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석
|
|
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
|
|
}
|
|
} 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) {
|
|
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)) {
|
|
println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.")
|
|
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
|
|
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
|
TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
|
|
} else {
|
|
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
|
|
|
|
KisTradeService.postOrder(stockCode, orderQty, 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
|
|
|
|
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 객체 통째로 전달
|
|
)
|
|
|
|
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.addLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가")
|
|
} else {
|
|
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
|
|
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
|
|
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
|
|
TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity)
|
|
|
|
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()}")
|
|
|
|
myOredsAndBalanceCodes.remove(dbItem.code)
|
|
TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림
|
|
|
|
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
|
executionCache.remove(orderNo)
|
|
}
|
|
}
|
|
} finally {
|
|
processingIds.remove(orderNo)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 자동 발굴 루프 시작 및 Watchdog 실행
|
|
*/
|
|
fun startAutoDiscoveryLoop() {
|
|
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분간 응답 없음). 강제 재시작합니다.")
|
|
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 {
|
|
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()
|
|
tradeService.postOrder(
|
|
stockCode = holding.code,
|
|
qty = holding.availOrderCount,
|
|
price = "0",
|
|
isBuy = false,
|
|
).onSuccess { newOrderNo ->
|
|
println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.")
|
|
}.onFailure {
|
|
}
|
|
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 ((!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//myOredsAndBalanceCodes.size < 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()}")
|
|
if (now.isAfter(KisSession.endTime())) {
|
|
executeClosingLiquidation(KisTradeService)
|
|
} else {
|
|
executeMarketLoop()
|
|
}
|
|
}
|
|
}
|
|
else ->{
|
|
waitTime = 3.0
|
|
}
|
|
}
|
|
} catch (e: TimeoutCancellationException) {
|
|
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
|
|
} catch (e: Exception) {
|
|
println("⚠️ [Loop Error] ${e.message}")
|
|
delay(1500)
|
|
}
|
|
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(10)) && now.isBefore(KisSession.startTime()) && !isSystemReadyToday) {
|
|
if (MarketUtil.canTradeToday()) {
|
|
SystemSleepPreventer.wakeDisplay()
|
|
shouldShowFullWindow = true
|
|
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
|
|
tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true)
|
|
} else {
|
|
println("💤 [System] 오늘은 휴장일(또는 주말)입니다. 대기 모드를 유지합니다.")
|
|
delay(3600_000) // 휴장일이면 1시간 뒤에 다시 체크하도록 긴 지연시간 부여
|
|
}
|
|
}
|
|
}
|
|
var loadedTops = mutableListOf<Pair<String, String>>()
|
|
|
|
fun poll100Stocks(): List<Pair<String, String>> {
|
|
val count = minOf(loadedTops.size, 150)
|
|
if (count == 0) return emptyList()
|
|
|
|
// 앞의 100개를 복사
|
|
val batch = loadedTops.subList(0, count).toList()
|
|
|
|
// 원본에서 삭제 (이 작업이 큐의 pop/remove 역할을 합니다)
|
|
loadedTops.subList(0, count).clear()
|
|
|
|
return batch
|
|
}
|
|
var currentBalance : UnifiedBalance? = null
|
|
var myOredsAndBalanceCodes : MutableSet<String> = mutableSetOf()
|
|
suspend fun checkBalance(isMorning: Boolean = true) {
|
|
if (isMorning) {
|
|
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
|
|
currentBalance?.let { currentBalance ->
|
|
if (LocalTime.now().isBefore(LocalTime.of(18,1))) {
|
|
TradingReportManager.recordAssetSnapshot(
|
|
if (LocalTime.now().isAfter(LocalTime.of(18, 0))
|
|
) SnapshotType.END else SnapshotType.MIDDLE, currentBalance, ""
|
|
)
|
|
}
|
|
}
|
|
|
|
if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
|
|
if (KisSession.tradeConfig.auto_cancel_pending_buy) {
|
|
checkAndCancelPendingBuyOrders()
|
|
}
|
|
} else {
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// 조건 A: 설정된 대기 시간 경과 여부
|
|
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 * 100)}%)로 취소 시도")
|
|
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() {
|
|
myOredsAndBalanceCodes.clear()
|
|
checkBalance()
|
|
val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
|
val myHoldings = currentBalance?.getHoldings()?.map {
|
|
myOredsAndBalanceCodes.add(it.code)
|
|
it.code }?.toSet() ?: emptySet()
|
|
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
|
|
myOredsAndBalanceCodes.add(it.code)
|
|
it.code
|
|
}
|
|
|
|
if (remainingCandidates.isEmpty()) {
|
|
if (loadedTops.size < 100) {
|
|
loadedTops.addAll(StockUniverseLoader.loadUniverse())
|
|
loadedTops.shuffle()
|
|
println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.")
|
|
}
|
|
poll100Stocks().forEach { (code, name) ->
|
|
addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name))
|
|
}
|
|
|
|
val candidates: MutableList<RankingStock> = fetchCandidates(KisTradeService).apply {
|
|
}.filter {
|
|
val rate = it.prdy_ctrt.toDouble()
|
|
val corpInfo = DartCodeManager.getCorpCode(it.code)
|
|
val isOk = (rate > 0 && rate < 15) || (rate < 0 && rate > -15)
|
|
if (corpInfo?.cName.isNullOrEmpty()) {
|
|
false
|
|
} else {
|
|
isOk
|
|
}
|
|
}
|
|
.filter { !it.name.contains("호스팩", true) }
|
|
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
|
|
.toMutableList()
|
|
|
|
if (reanalysisList.isNotEmpty()) {
|
|
candidates.addAll(reanalysisList)
|
|
}
|
|
reanalysisList.clear()
|
|
remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList}
|
|
.distinctBy { it.code })
|
|
} else {
|
|
println("미확인 데이터 ${remainingCandidates.size}")
|
|
}
|
|
|
|
var totalCount = remainingCandidates.size
|
|
println("후보군 조건 충족 총 개수 : ${totalCount}")
|
|
val iterator = remainingCandidates.iterator()
|
|
while (iterator.hasNext()) {
|
|
totalCount--
|
|
val stock = iterator.next()
|
|
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(100)
|
|
}
|
|
}
|
|
sellSchedule()
|
|
}
|
|
println("⏱️ [Cycle End] ${LocalTime.now()}")
|
|
}
|
|
private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장
|
|
suspend fun sellSchedule() {
|
|
if (KisSession.config.take_profit == false) {
|
|
|
|
} else {
|
|
val now = LocalTime.now()
|
|
val currentMinute = now.minute
|
|
if (now.hour == 9 && currentMinute % 2 == 1
|
|
) {
|
|
if (lastForceCheckMinute != currentMinute) {
|
|
TradingLogStore.addAnalyzer(
|
|
" - ",
|
|
" - ",
|
|
"⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.",
|
|
true
|
|
)
|
|
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
|
|
checkBalance()
|
|
lastForceCheckMinute = currentMinute // 실행 완료 기록
|
|
}
|
|
} else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt) || (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 1)) {
|
|
if (lastForceCheckMinute != currentMinute) {
|
|
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)
|
|
}
|
|
}
|
|
lastForceCheckMinute = currentMinute // 실행 완료 기록
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
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
|
|
}
|
|
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 (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
|
|
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
|
|
return@withTimeout
|
|
}
|
|
|
|
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
|
|
|
|
|
|
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()
|
|
}
|
|
}
|
|
if (analyzer.isValid()) {
|
|
RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess ->
|
|
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
|
|
}
|
|
}
|
|
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(tradeService: KisTradeService) {
|
|
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
|
|
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
|
|
val realHoldings = balanceResult?.getHoldings()?.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)
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|