This commit is contained in:
2026-09-08 11:30:43 +09:00
parent 5e90ee39bc
commit 3998cf159a
+411 -103
View File
@@ -55,6 +55,7 @@ import kotlin.math.max
// service/AutoTradingManager.kt // service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean) -> Unit typealias TradingDecisionCallback = (TradingDecision?, Boolean) -> Unit
object AutoTradingManager { object AutoTradingManager {
@@ -71,6 +72,7 @@ object AutoTradingManager {
var ONE_STOCK_ALYSIS_TIME = KisSession.tradeConfig.ONE_STOCK_ALYSIS_TIME var ONE_STOCK_ALYSIS_TIME = KisSession.tradeConfig.ONE_STOCK_ALYSIS_TIME
fun isRunning(): Boolean = discoveryJob?.isActive == true fun isRunning(): Boolean = discoveryJob?.isActive == true
private var remainingCandidates = mutableListOf<RankingStock>() private var remainingCandidates = mutableListOf<RankingStock>()
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항) // private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
private val reanalysisList = mutableListOf<RankingStock>() private val reanalysisList = mutableListOf<RankingStock>()
private val retryCountMap = mutableMapOf<String, Int>() private val retryCountMap = mutableMapOf<String, Int>()
@@ -92,7 +94,13 @@ object AutoTradingManager {
if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) { if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) {
shouldShowFullWindow = true shouldShowFullWindow = true
// SystemSleepPreventer.wakeDisplay() // SystemSleepPreventer.wakeDisplay()
} else if ((now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0)))) { } else if ((now.isAfter(LocalTime.of(23, 50)) && now.isBefore(
LocalTime.of(
8,
0
)
))
) {
// SystemSleepPreventer.sleepDisplay() // SystemSleepPreventer.sleepDisplay()
} }
// if (!isTradingDay) { // if (!isTradingDay) {
@@ -119,31 +127,54 @@ object AutoTradingManager {
decision.analyzer?.let { a -> decision.analyzer?.let { a ->
val volatility = a?.calculateVolatilityForecast(a.daily, 20) val volatility = a?.calculateVolatilityForecast(a.daily, 20)
volatility?.let { volatility?.let {
maxRealisticProfitRate = ((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0 maxRealisticProfitRate =
((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0
} }
} }
// 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준) // 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준)
// 2. 시스템 기본 설정 수익률과 비교 // 2. 시스템 기본 설정 수익률과 비교
val baseProfitRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(grade.profitGuide) val baseProfitRate =
KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(
grade.profitGuide
)
// 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤 // 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤
val finalProfitRate = if (maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate) { val finalProfitRate =
if (maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate) {
max(maxRealisticProfitRate, 0.05) max(maxRealisticProfitRate, 0.05)
} else { } else {
baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행 baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행
} }
// 2. 최종 매수 실행 // 2. 최종 매수 실행
val gradeRate = KisSession.config.getValues(grade.allocationRate) val gradeRate = KisSession.config.getValues(grade.allocationRate)
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate val maxBudget =
KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
decision.maxRealisticProfitRate = maxRealisticProfitRate decision.maxRealisticProfitRate = maxRealisticProfitRate
TradingLogStore.addLog(decision,"BUY",decision.summary(KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))) TradingLogStore.addLog(
var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings()?.any { it.code.equals(decision.stockCode) && it.quantity.toInt() > 2 && it.availOrderCount.toInt() > 0} ?: false 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
val calculatedQty =
if (hasCodes == true) KisSession.tradeConfig.lowerAverageStockCount else (maxBudget / decision.currentPrice).toInt()
.coerceAtLeast(1)
if (hasCodes == true) { if (hasCodes == true) {
TradingLogStore.addNotice(decision.stockName,decision.stockCode,"물타기 시도 1주 매수") TradingLogStore.addNotice(
decision.stockName,
decision.stockCode,
"물타기 시도 ${calculatedQty}주 매수"
)
} }
val calculatedQty = if(hasCodes == true) KisSession.tradeConfig.lowerAverageStockCount else (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
excuteTrade( excuteTrade(
decision = decision, decision = decision,
orderQty = calculatedQty.toString(), orderQty = calculatedQty.toString(),
@@ -183,11 +214,13 @@ object AutoTradingManager {
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
} }
midLongAvg >= 60.0 -> { midLongAvg >= 60.0 -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
else InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_2_HIGH_RISK
} }
else -> { else -> {
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
else InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_1_SPECULATIVE
@@ -202,6 +235,7 @@ object AutoTradingManager {
rawGrade = when (rawGrade) { rawGrade = when (rawGrade) {
InvestmentGrade.LEVEL_1_SPECULATIVE, InvestmentGrade.LEVEL_1_SPECULATIVE,
InvestmentGrade.LEVEL_2_HIGH_RISK -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND InvestmentGrade.LEVEL_2_HIGH_RISK -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
else -> rawGrade else -> rawGrade
} }
@@ -210,8 +244,10 @@ object AutoTradingManager {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, InvestmentGrade.LEVEL_5_STRONG_RECOMMEND,
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND, InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_1_SPECULATIVE InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_1_SPECULATIVE
InvestmentGrade.LEVEL_2_HIGH_RISK, InvestmentGrade.LEVEL_2_HIGH_RISK,
InvestmentGrade.LEVEL_1_SPECULATIVE -> InvestmentGrade.LEVEL_0_SPECULATIVE InvestmentGrade.LEVEL_1_SPECULATIVE -> InvestmentGrade.LEVEL_0_SPECULATIVE
else -> InvestmentGrade.LEVEL_0_SPECULATIVE else -> InvestmentGrade.LEVEL_0_SPECULATIVE
} }
} }
@@ -228,11 +264,19 @@ object AutoTradingManager {
} }
} }
fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK, hasCode: Boolean) { fun excuteTrade(
decision: TradingDecision,
orderQty: String,
profitRate1: Double?,
investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK,
hasCode: Boolean
) {
scope.launch { scope.launch {
var basePrice = decision.currentPrice var basePrice = decision.currentPrice
val tickSize = MarketUtil.getTickSize(basePrice) val tickSize = MarketUtil.getTickSize(basePrice)
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) val oneTickLowerPrice =
basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide)
.toInt())
var stockCode = decision.stockCode var stockCode = decision.stockCode
var stockName = decision.stockName var stockName = decision.stockName
val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble())
@@ -241,11 +285,26 @@ object AutoTradingManager {
if (!canAddNewPosition(maxStocks)) { if (!canAddNewPosition(maxStocks)) {
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단") TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode, hts_kor_isnm = stockName)) addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode, hts_kor_isnm = stockName))
TradingLogStore.addWatchLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") TradingLogStore.addWatchLog(
decision,
"WATCH",
"매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가"
)
} else if (KisSession.isAvailBuyTime(LocalTime.now()) || hasCode) { } else if (KisSession.isAvailBuyTime(LocalTime.now()) || hasCode) {
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice hasStocks : ${stockCode.contains(stockCode)}" ) println(
"basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice hasStocks : ${
stockCode.contains(
stockCode
)
}"
)
var realOrderQty = orderQty var realOrderQty = orderQty
KisTradeService.postOrder(stockCode, realOrderQty, finalPrice.toLong().toString(), isBuy = true) KisTradeService.postOrder(
stockCode,
realOrderQty,
finalPrice.toLong().toString(),
isBuy = true
)
.onSuccess { realOrderNo -> .onSuccess { realOrderNo ->
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
TradingLogStore.addLog( TradingLogStore.addLog(
@@ -257,21 +316,27 @@ object AutoTradingManager {
val sRate = -1.5 val sRate = -1.5
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
val effectiveProfitRate = val effectiveProfitRate =
(profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax (profitRate1
?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
try { try {
var oldTarget = currentBalance?.getHoldings()?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) } var oldTarget = currentBalance?.getHoldings()
?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) }
if (KisSession.tradeConfig.lowerAveragePrice && hasCode && oldTarget != null) { if (KisSession.tradeConfig.lowerAveragePrice && hasCode && oldTarget != null) {
var avgPrive = oldTarget.avgPrice.toDouble() var avgPrive = oldTarget.avgPrice.toDouble()
var qty = oldTarget.quantity.toDouble() var qty = oldTarget.quantity.toDouble()
basePrice = avgPrive * 1.5//((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt())) basePrice =
avgPrive * 1.5//((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt()))
println("물타기 ${avgPrive}, ${qty} ${basePrice}") println("물타기 ${avgPrive}, ${qty} ${basePrice}")
} }
} catch (e:Exception) {e.printStackTrace()} } catch (e: Exception) {
e.printStackTrace()
}
val calculatedTarget = val calculatedTarget =
MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) val calculatedStop =
MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
if (!hasCode) { if (!hasCode) {
DatabaseFactory.saveAutoTrade( DatabaseFactory.saveAutoTrade(
@@ -330,11 +395,27 @@ object AutoTradingManager {
TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패") TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패")
} }
} }
} else if (!hasCode && KisSession.isAvailBuyTime(LocalTime.now())) {
AutoTradingManager.addToReanalysis(
RankingStock(
mksc_shrn_iscd = stockCode,
hts_kor_isnm = stockName
)
)
TradingLogStore.addWatchLog(
decision,
"WATCH",
"매수 시간 외 분석 => 재분석 대기열에 추가"
)
} else { } else {
val unfilledResult = KisTradeService.fetchUnfilledOrders() val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response -> unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 매수시간 종료 후 모든 매수 취소") TradingLogStore.addNotice(
order.prdt_name,
order.pdno,
"[주문 취소] 매수시간 종료 후 모든 매수 취소"
)
KisTradeService.cancelOrder( KisTradeService.cancelOrder(
order.ord_no, // 원주문번호 order.ord_no, // 원주문번호
order.pdno order.pdno
@@ -342,9 +423,12 @@ object AutoTradingManager {
} }
} }
} }
} }
} }
var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy ->
var onExecutionReceived: ((String, String, String, String, Boolean) -> Unit)? =
{ code, qty, price, orderNo, isBuy ->
scope.launch { scope.launch {
val exec = ExecutionData(orderNo, code, price, qty, isBuy) val exec = ExecutionData(orderNo, code, price, qty, isBuy)
println("exec >> ${exec}") println("exec >> ${exec}")
@@ -371,13 +455,16 @@ object AutoTradingManager {
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다. // 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다. // 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity) 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 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) { if (hasCodes) {
actualBuyPrice = actualBuyPrice * 1.1 actualBuyPrice = actualBuyPrice * 1.1
} }
val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05 val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05
val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) val finalTargetPrice =
MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}") println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}")
@@ -394,11 +481,20 @@ object AutoTradingManager {
stockName = dbItem.name, stockName = dbItem.name,
isBuy = false, isBuy = false,
orderQty = dbItem.quantity, orderQty = dbItem.quantity,
reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)}% 도달을 위한 익절 주문", reason = "🎯 목표 수익률 ${
String.format(
"%.2f",
finalProfitRate
)
}% 도달을 위한 익절 주문",
holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다! holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다!
decision = null decision = null
) )
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) DatabaseFactory.updateStatusAndOrderNo(
dbItem.id!!,
TradeStatus.SELLING,
newSellOrderNo
)
executionCache.remove(orderNo) executionCache.remove(orderNo)
} }
} else if (dbItem.status == TradeStatus.SELLING) { } else if (dbItem.status == TradeStatus.SELLING) {
@@ -409,7 +505,12 @@ object AutoTradingManager {
TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty) TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty)
println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}") println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}")
TradingLogStore.addSellLog(dbItem.name,actualSellPrice.toString(),"SELL","매도 완료") TradingLogStore.addSellLog(
dbItem.name,
actualSellPrice.toString(),
"SELL",
"매도 완료"
)
TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림 TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림
@@ -433,7 +534,7 @@ object AutoTradingManager {
watchdogJob = scope.launch { watchdogJob = scope.launch {
val activeTrades = DatabaseFactory.findAllMonitoringTrades() val activeTrades = DatabaseFactory.findAllMonitoringTrades()
var now = LocalTime.now(ZoneId.of("Asia/Seoul")) var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (doStart && activeTrades.isNotEmpty() && !KisSession.isAvailBuyTime(now)) { if (doStart && activeTrades.isNotEmpty() && !KisSession.isMarketOpenTime(now)) {
executeClosingLiquidation(activeTrades) executeClosingLiquidation(activeTrades)
} }
while (isActive) { while (isActive) {
@@ -450,7 +551,11 @@ object AutoTradingManager {
runDiscoveryLoop(globalCallback) runDiscoveryLoop(globalCallback)
} }
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { suspend fun sellingAfterMarketOnePrice(
tradeService: KisTradeService,
balance: UnifiedBalance,
marketCode: String = "Y"
) {
balance.getHoldings().forEach { holding -> balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)) { if (BLACKLISTEDSTOCKCODES.contains(holding.code)) {
println("❌ 차단 처리된 주식 : ${holding.name}") println("❌ 차단 처리된 주식 : ${holding.name}")
@@ -462,9 +567,12 @@ object AutoTradingManager {
} else { } else {
val now = LocalTime.now() val now = LocalTime.now()
val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(16, 0))) { val targetProfitLimit =
if (holding.isTodayEntry && now.isBefore(LocalTime.of(16, 0))) {
// 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도) // 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도)
KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX) KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX
)
} else { } else {
// 오래 보유한 종목: 기존 설정값 준수 (예: 3.0% 등) // 오래 보유한 종목: 기존 설정값 준수 (예: 3.0% 등)
KisSession.config.SELL_PROFIT KisSession.config.SELL_PROFIT
@@ -492,7 +600,8 @@ object AutoTradingManager {
"SELL", "SELL",
"🎊 ${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료" "🎊 ${if (marketCode.equals("Y")) "시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료"
) )
DatabaseFactory.saveAutoTrade(AutoTradeItem( DatabaseFactory.saveAutoTrade(
AutoTradeItem(
orderNo = newOrderNo, orderNo = newOrderNo,
code = holding.code, code = holding.code,
name = holding.name, name = holding.name,
@@ -503,7 +612,8 @@ object AutoTradingManager {
stopLossPrice = 0.0, stopLossPrice = 0.0,
status = "SELLING", status = "SELLING",
isDomestic = true isDomestic = true
)) )
)
syncAndExecute(newOrderNo) syncAndExecute(newOrderNo)
}.onFailure { }.onFailure {
TradingLogStore.addSellLog( TradingLogStore.addSellLog(
@@ -518,10 +628,27 @@ object AutoTradingManager {
if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0 if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0
&& holding != null && holding.quantity.toInt() > 0 && holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0 && holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE) && holding.profitRate.toDouble() <= KisSession.config.getValues(
&& holding.profitRate.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE) ConfigIndex.LOSS_MINRATE
&& 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)}") && 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() val profit = holding.profitRate.toDouble()
TradingLogStore.addNotice( TradingLogStore.addNotice(
"보유주식[${holding.name}]", "보유주식[${holding.name}]",
@@ -563,7 +690,9 @@ object AutoTradingManager {
targetPrice = targetPrice targetPrice = targetPrice
isBefore930 = true isBefore930 = true
} else { } else {
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) targetPrice = MarketUtil.roundToTickSize(
targetPrice + MarketUtil.getTickSize(targetPrice)
)
} }
println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
tradeService.postOrder( tradeService.postOrder(
@@ -579,7 +708,8 @@ object AutoTradingManager {
"SELL", "SELL",
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료" "🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
) )
DatabaseFactory.saveAutoTrade(AutoTradeItem( DatabaseFactory.saveAutoTrade(
AutoTradeItem(
orderNo = newOrderNo, orderNo = newOrderNo,
code = holding.code, code = holding.code,
name = holding.name, name = holding.name,
@@ -590,7 +720,8 @@ object AutoTradingManager {
stopLossPrice = 0.0, stopLossPrice = 0.0,
status = "SELLING", status = "SELLING",
isDomestic = true isDomestic = true
)) )
)
syncAndExecute(newOrderNo) syncAndExecute(newOrderNo)
}.onFailure { }.onFailure {
TradingLogStore.addSellLog( TradingLogStore.addSellLog(
@@ -608,9 +739,12 @@ object AutoTradingManager {
&& holding.availOrderCount.toInt() > 0 && holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.tradeConfig.autoSellOrderMin && holding.profitRate.toDouble() <= KisSession.tradeConfig.autoSellOrderMin
&& holding.profitRate.toDouble() >= KisSession.tradeConfig.autoSellOrderMax && holding.profitRate.toDouble() >= KisSession.tradeConfig.autoSellOrderMax
&& holding.avgPrice.toDouble() > holding.currentPrice.toDouble()) { && holding.avgPrice.toDouble() > holding.currentPrice.toDouble()
) {
var targetPrice = holding.avgPrice.toDouble() var targetPrice = holding.avgPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend) targetPrice = MarketUtil.roundToTickSize(
targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend
)
tradeService.postOrder( tradeService.postOrder(
stockCode = holding.code, stockCode = holding.code,
qty = holding.availOrderCount, qty = holding.availOrderCount,
@@ -632,13 +766,32 @@ object AutoTradingManager {
} else if (KisSession.config.stop_Loss } else if (KisSession.config.stop_Loss
&& holding != null && holding.quantity.toInt() > 0 && holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0 && holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE) && holding.profitRate.toDouble() <= KisSession.config.getValues(
&& holding.profitRate.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE) ConfigIndex.LOSS_MINRATE
&& 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)}") && 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() val profit = holding.profitRate.toDouble()
var targetPrice = holding.currentPrice.toDouble() var targetPrice = holding.currentPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend) targetPrice = MarketUtil.roundToTickSize(
targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend
)
tradeService.postOrder( tradeService.postOrder(
@@ -666,7 +819,10 @@ object AutoTradingManager {
} }
} }
private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding, isForce : Boolean = false) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 private suspend fun analyzeDeepLossHoldingsAfterMarket(
holding: UnifiedStockHolding,
isForce: Boolean = false
) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
val now = LocalTime.now() val now = LocalTime.now()
val currentMinute = now.minute val currentMinute = now.minute
if ((holding.availOrderCount.toInt() if ((holding.availOrderCount.toInt()
@@ -677,7 +833,8 @@ object AutoTradingManager {
if (profit <= lossThreshold) { if (profit <= lossThreshold) {
println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...") println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...")
val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull() val dailyData =
KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull()
if (!dailyData.isNullOrEmpty()) { if (!dailyData.isNullOrEmpty()) {
val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val analyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
@@ -694,7 +851,8 @@ object AutoTradingManager {
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간 // 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) { if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) {
advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" advice =
"📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
TradingLogStore.addNotice( TradingLogStore.addNotice(
"보유주식[${holding.name}]", "보유주식[${holding.name}]",
holding.code, holding.code,
@@ -703,7 +861,8 @@ object AutoTradingManager {
} }
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때 // 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
else if (isTrendBroken && currentPrice > lowerBand * 1.1) { else if (isTrendBroken && currentPrice > lowerBand * 1.1) {
advice = "🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다." advice =
"🚨 [손절 경고] 20일 추세가 완전히 무너졌으며, 아직 바닥(하단 밴드)도 확인되지 않았습니다. 추가 하락(지하실) 위험이 크므로 리스크 관리(손절)가 필요합니다."
TradingLogStore.addNotice( TradingLogStore.addNotice(
"보유주식[${holding.name}]", "보유주식[${holding.name}]",
holding.code, holding.code,
@@ -763,7 +922,11 @@ object AutoTradingManager {
LlamaServerManager.startServer(binPath, config.modelPath, port = LLM_PORT) LlamaServerManager.startServer(binPath, config.modelPath, port = LLM_PORT)
} }
if (config.embedModelPath.isNotEmpty()) { if (config.embedModelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.embedModelPath, port = EMBEDDING_PORT) LlamaServerManager.startServer(
binPath,
config.embedModelPath,
port = EMBEDDING_PORT
)
} }
KisWebSocketManager.connect() KisWebSocketManager.connect()
isSystemReadyToday = true isSystemReadyToday = true
@@ -772,7 +935,8 @@ object AutoTradingManager {
println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.") println("❌ [System] 토큰 갱신 실패. 2분 후 재시도합니다.")
} }
} }
} catch (e: Exception) {} } catch (e: Exception) {
}
} }
var onMarketClosed: (() -> Unit)? = null var onMarketClosed: (() -> Unit)? = null
@@ -795,9 +959,16 @@ object AutoTradingManager {
now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime()) -> { now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime()) -> {
prepareMarketOpen(now) prepareMarketOpen(now)
} }
now.isBefore(KisSession.endTime()) && now.isAfter(KisSession.startTime()) -> { now.isBefore(KisSession.endTime()) && now.isAfter(KisSession.startTime()) -> {
waitTime = 0.2 waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(
LocalTime.of(
15,
30
)
)
) {
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
if (isSystemReadyToday) { if (isSystemReadyToday) {
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.") println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
@@ -817,6 +988,7 @@ object AutoTradingManager {
} }
} }
} }
else -> { else -> {
waitTime = 3.0 waitTime = 3.0
} }
@@ -844,7 +1016,10 @@ object AutoTradingManager {
isSystemReadyToday = false isSystemReadyToday = false
shouldShowFullWindow = false shouldShowFullWindow = false
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if (now.isAfter(KisSession.startTime().minusMinutes(20)) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow) { } else if (now.isAfter(
KisSession.startTime().minusMinutes(20)
) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow
) {
if (MarketUtil.canTradeToday()) { if (MarketUtil.canTradeToday()) {
shouldShowFullWindow = true shouldShowFullWindow = true
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
@@ -855,13 +1030,14 @@ object AutoTradingManager {
} }
} }
} }
var loadedTops = mutableListOf<Pair<String, String>>() var loadedTops = mutableListOf<Pair<String, String>>()
var defaultStockCount = 30 var defaultStockCount = 30
var currentBalance: UnifiedBalance? = null var currentBalance: UnifiedBalance? = null
private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond) private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond)
private val FETCH_INTERVAL = 2 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms) private val FETCH_INTERVAL = 1 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms)
suspend fun checkBalance() { suspend fun checkBalance() {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
@@ -878,8 +1054,15 @@ object AutoTradingManager {
// 30분이 지나지 않았다면 기존에 저장된 currentBalance를 그대로 사용 // 30분이 지나지 않았다면 기존에 저장된 currentBalance를 그대로 사용
println("${(FETCH_INTERVAL / (1000 * 60))}분이 지나지 않아 기존 잔고 데이터 유지 (남은 시간: ${(FETCH_INTERVAL - (currentTime - lastFetchTime)) / 1000}초)") println("${(FETCH_INTERVAL / (1000 * 60))}분이 지나지 않아 기존 잔고 데이터 유지 (남은 시간: ${(FETCH_INTERVAL - (currentTime - lastFetchTime)) / 1000}초)")
} }
if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } if (KisSession.config.take_profit) currentBalance?.let {
if (KisSession.tradeConfig.auto_cancel_pending_buy) { checkAndCancelPendingBuyOrders() } resumePendingSellOrders(
KisTradeService,
it
)
}
if (KisSession.tradeConfig.auto_cancel_pending_buy) {
checkAndCancelPendingBuyOrders()
}
} }
@@ -897,13 +1080,23 @@ object AutoTradingManager {
val elapsedMillis = currentTime - orderTimeMillis val elapsedMillis = currentTime - orderTimeMillis
if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time) { if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time) {
// 2. 현재가 조회 (가격을 비교하기 위해) // 2. 현재가 조회 (가격을 비교하기 위해)
val currentPrice = KisTradeService.fetchCurrentPrice(order.pdno).getOrNull()?.stck_prpr?.toDouble() ?: 0.0 val currentPrice = KisTradeService.fetchCurrentPrice(order.pdno)
.getOrNull()?.stck_prpr?.toDouble() ?: 0.0
val orderedPrice = order.ord_unpr.toDoubleOrNull() ?: 0.0 val orderedPrice = order.ord_unpr.toDoubleOrNull() ?: 0.0
// 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때) // 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때)
val priceGap = Math.abs(currentPrice - orderedPrice) / orderedPrice val priceGap = Math.abs(currentPrice - orderedPrice) / orderedPrice
println("checkAndCancelPendingBuyOrders order $order ${elapsedMillis / 1000L}${priceGap}% 차이") println("checkAndCancelPendingBuyOrders order $order ${elapsedMillis / 1000L}${priceGap}% 차이")
if (priceGap >= KisSession.tradeConfig.auto_cancel_pending_rate) { if (priceGap >= KisSession.tradeConfig.auto_cancel_pending_rate) {
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] ${order.prdt_name} (${order.pdno}) - 시간경과 및 가격괴리(${String.format("%.2f", priceGap)}%)로 취소 시도") TradingLogStore.addNotice(
order.prdt_name,
order.pdno,
"[주문 취소] ${order.prdt_name} (${order.pdno}) - 시간경과 및 가격괴리(${
String.format(
"%.2f",
priceGap
)
}%) 취소 시도"
)
KisTradeService.cancelOrder( KisTradeService.cancelOrder(
order.ord_no, // 원주문번호 order.ord_no, // 원주문번호
order.pdno order.pdno
@@ -920,7 +1113,11 @@ object AutoTradingManager {
val unfilledResult = KisTradeService.fetchUnfilledOrders() val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response -> unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "01" }.forEach { order -> response.filter { it.sll_buy_dvsn_cd == "01" }.forEach { order ->
TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] 정규장 시작전 모든 매도 주문 취소") TradingLogStore.addNotice(
order.prdt_name,
order.pdno,
"[주문 취소] 정규장 시작전 모든 매도 주문 취소"
)
KisTradeService.cancelOrder( KisTradeService.cancelOrder(
order.ord_no, // 원주문번호 order.ord_no, // 원주문번호
order.pdno order.pdno
@@ -946,9 +1143,12 @@ object AutoTradingManager {
suspend fun executeMarketLoop() { suspend fun executeMarketLoop() {
checkBalance() checkBalance()
var myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong() 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()) myCash = max(myCash, KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong())
val myHoldings = currentBalance?.getHoldings()?.filter { !it.isTodayEntry }?.map { it.code }?.toSet() ?: emptySet() val myHoldings =
currentBalance?.getHoldings()?.filter { !it.isTodayEntry }?.map { it.code }?.toSet()
?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
var now = LocalTime.now(ZoneId.of("Asia/Seoul")) var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (remainingCandidates.isEmpty()) { if (remainingCandidates.isEmpty()) {
@@ -960,7 +1160,12 @@ object AutoTradingManager {
val count = minOf(loadedTops.size, defaultStockCount) val count = minOf(loadedTops.size, defaultStockCount)
for (i in 0..<count) { for (i in 0..<count) {
loadedTops.removeFirst().let { loadedTops.removeFirst().let {
addToReanalysis(RankingStock(mksc_shrn_iscd = it.first, hts_kor_isnm = it.second)) addToReanalysis(
RankingStock(
mksc_shrn_iscd = it.first,
hts_kor_isnm = it.second
)
)
} }
} }
@@ -968,7 +1173,10 @@ object AutoTradingManager {
}.filter { }.filter {
val rate = it.prdy_ctrt.toDouble() val rate = it.prdy_ctrt.toDouble()
val corpInfo = DartCodeManager.getCorpCode(it.code) val corpInfo = DartCodeManager.getCorpCode(it.code)
val isOk = (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs(KisSession.tradeConfig.minusFilter) * -1)) val isOk =
(rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs(
KisSession.tradeConfig.minusFilter
) * -1))
if (corpInfo?.cName.isNullOrEmpty()) { if (corpInfo?.cName.isNullOrEmpty()) {
false false
@@ -976,7 +1184,8 @@ object AutoTradingManager {
it.code !in pendingStocks && it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } && it.code !in executionCache.values.map { it.code } &&
it.code !in failList && it.code !in failList &&
it.code !in isSafetyBeltStockCodes){ it.code !in isSafetyBeltStockCodes
) {
isOk isOk
} else { } else {
false false
@@ -995,23 +1204,33 @@ object AutoTradingManager {
it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount && it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount &&
it.profitRate.toDouble() < 0.0 && it.profitRate.toDouble() < 0.0 &&
it.profitRate.toDouble() < (abs(KisSession.tradeConfig.lowerAverageMaxRate) * -1) && it.profitRate.toDouble() < (abs(KisSession.tradeConfig.lowerAverageMaxRate) * -1) &&
it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1)) it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1)
{ ) {
candidates.add(RankingStock(mksc_shrn_iscd = it.code, hts_kor_isnm = it.name)) candidates.add(
RankingStock(
mksc_shrn_iscd = it.code,
hts_kor_isnm = it.name
)
)
println("물타기 대상 추가 ${it.name}[${it.code}]") println("물타기 대상 추가 ${it.name}[${it.code}]")
var oldTarget = it var oldTarget = it
if (oldTarget != null) { if (oldTarget != null) {
var avgPrive = oldTarget.avgPrice.toDouble() var avgPrive = oldTarget.avgPrice.toDouble()
var qty = oldTarget.quantity.toDouble() var qty = oldTarget.quantity.toDouble()
var basePrice = ((avgPrive * qty) + it.currentPrice.toDouble()).div(qty!!.toInt() + 1) var basePrice =
((avgPrive * qty) + it.currentPrice.toDouble()).div(qty!!.toInt() + 1)
println("물타기 ${avgPrive}, ${qty} ${basePrice}") println("물타기 ${avgPrive}, ${qty} ${basePrice}")
} }
} }
} }
} }
remainingCandidates.addAll(candidates.filter { remainingCandidates.addAll(candidates.filter {
(if (KisSession.tradeConfig.lowerAveragePrice) { true } else {it.code !in myHoldings}) && (if (KisSession.tradeConfig.lowerAveragePrice) {
true
} else {
it.code !in myHoldings
}) &&
it.code !in pendingStocks && it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } && it.code !in executionCache.values.map { it.code } &&
it.code !in failList && it.code !in failList &&
@@ -1047,6 +1266,7 @@ object AutoTradingManager {
} }
println("⏱️ [Cycle End] ${LocalTime.now()}") println("⏱️ [Cycle End] ${LocalTime.now()}")
} }
// private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장 // private var lastForceCheckMinute = -1 // 마지막으로 강제 체크를 수행한 '분'을 저장
private val executionCountMap = mutableMapOf<String, Int>() private val executionCountMap = mutableMapOf<String, Int>()
suspend fun sellSchedule() { suspend fun sellSchedule() {
@@ -1054,7 +1274,9 @@ object AutoTradingManager {
val now = LocalTime.now() val now = LocalTime.now()
val timeKey = String.format("%02d:%02d", now.hour, now.minute) // 예: "09:05" val timeKey = String.format("%02d:%02d", now.hour, now.minute) // 예: "09:05"
val currentCount = executionCountMap.getOrDefault(timeKey, 0) val currentCount = executionCountMap.getOrDefault(timeKey, 0)
if (currentCount >= KisSession.tradeConfig.excuteCountOnMin) { return } if (currentCount >= KisSession.tradeConfig.excuteCountOnMin) {
return
}
var isExecuted = false var isExecuted = false
val currentMinute = now.minute val currentMinute = now.minute
@@ -1086,8 +1308,14 @@ object AutoTradingManager {
} else if ( } else if (
( (
(now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || (now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) ||
(now.isAfter(LocalTime.of(15,40)) && now.isBefore(LocalTime.of(20,0)) && KisSession.tradeConfig.after_nxt) (now.isAfter(LocalTime.of(15, 40)) && now.isBefore(
) && (currentMinute % 2 == 0)) { LocalTime.of(
20,
0
)
) && KisSession.tradeConfig.after_nxt)
) && (currentMinute % 2 == 0)
) {
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
" - ", " - ",
" - ", " - ",
@@ -1105,7 +1333,9 @@ object AutoTradingManager {
} }
isExecuted = true isExecuted = true
} }
if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 } if (isExecuted) {
executionCountMap[timeKey] = currentCount + 1
}
if (now.hour >= 20) { if (now.hour >= 20) {
executionCountMap.clear() executionCountMap.clear()
noticeFilter.clear() noticeFilter.clear()
@@ -1121,8 +1351,14 @@ object AutoTradingManager {
// println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록") // println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
} }
} }
val failList = arrayListOf<String>() val failList = arrayListOf<String>()
private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) { private suspend fun processSingleStock(
stock: RankingStock,
myCash: Long,
tradeService: KisTradeService,
callback: TradingDecisionCallback
) {
try { try {
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX)
@@ -1134,7 +1370,9 @@ object AutoTradingManager {
print("-> 기업명을 못찾아서 제외 | ") print("-> 기업명을 못찾아서 제외 | ")
return@withTimeout return@withTimeout
} }
if(currentBalance?.getHoldings()?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2} == true) { if (currentBalance?.getHoldings()
?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2 } == true
) {
println("물타기 대상 분석") println("물타기 대상 분석")
} }
callback(TradingDecision().apply { callback(TradingDecision().apply {
@@ -1144,10 +1382,12 @@ object AutoTradingManager {
}, false) }, false)
val dailyData = val dailyData =
tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull()
?: return@withTimeout
val today = dailyData.lastOrNull() ?: null val today = dailyData.lastOrNull() ?: null
var rate = today?.getFluctuationRate() ?: 0.0 var rate = today?.getFluctuationRate() ?: 0.0
val isOk = ((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1))) val isOk =
((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1)))
delay(50) delay(50)
// 1. var 대신 val을 사용해야 아래에서 스마트 캐스트가 작동하여 !!를 안 써도 됩니다. // 1. var 대신 val을 사용해야 아래에서 스마트 캐스트가 작동하여 !!를 안 써도 됩니다.
val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull() val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull()
@@ -1176,14 +1416,14 @@ object AutoTradingManager {
} }
// 🌟 [추가] 고도화된 사전 필터링 (검문소) // 🌟 [추가] 고도화된 사전 필터링 (검문소)
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) // 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?)
println("(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.3).toInt()}") println("(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.3).toInt()}")
val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20) val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, 20)
val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 val expectedProfitRate =
((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0
// 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?) // 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?)
val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0) val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0)
@@ -1198,17 +1438,26 @@ object AutoTradingManager {
// 🌟 [수정] 조건 통합 (OR 조건) // 🌟 [수정] 조건 통합 (OR 조건)
val isProfitable = expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate val isProfitable =
expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과 // 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend val isValidEntryTiming =
(dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend
val currentAtr = tempAnalyzer.calculateATR(dailyData) val currentAtr = tempAnalyzer.calculateATR(dailyData)
if (!isProfitable || !isValidEntryTiming) { if (!isProfitable || !isValidEntryTiming) {
print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ") print(
"-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${
"%.1f".format(
dailyStats.avgReboundPeriod
)
}, 진입권: $isValidEntryTiming) | "
)
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감 return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
} }
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr) val dropPrediction =
tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr)
if (dropPrediction != null) { if (dropPrediction != null) {
// 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지 // 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지
@@ -1239,16 +1488,30 @@ object AutoTradingManager {
println("[${stock.name}] 배당 정보 무관 함.") println("[${stock.name}] 배당 정보 무관 함.")
} }
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)") println(
"🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${
"%.1f".format(
expectedProfitRate
)
}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}, 진입권: $isValidEntryTiming)"
)
if (!isSafetyBeltStockCodes.contains(stock.code)) { if (!isSafetyBeltStockCodes.contains(stock.code)) {
val analyzer = coroutineScope { val analyzer = coroutineScope {
val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) } val min30 = async {
tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList())
}
delay(20) delay(20)
val weekly = val weekly =
async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) } async {
tradeService.fetchPeriodChartData(stock.code, "W", true)
.getOrDefault(emptyList())
}
delay(20) delay(20)
val monthly = val monthly =
async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) } async {
tradeService.fetchPeriodChartData(stock.code, "M", true)
.getOrDefault(emptyList())
}
delay(20) delay(20)
TechnicalAnalyzer().apply { TechnicalAnalyzer().apply {
this.daily = dailyData this.daily = dailyData
@@ -1263,8 +1526,16 @@ object AutoTradingManager {
if (analyzer.isValid()) { if (analyzer.isValid()) {
println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})") println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess -> RagService.processStock(
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess) currentPrice,
analyzer,
stock.name,
stock.code
) { decision, isSuccess ->
callback(
decision?.apply { this.currentPrice = currentPrice },
isSuccess
)
} }
} else { } else {
println("✅ [분석 실패] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})") println("✅ [분석 실패] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
@@ -1280,20 +1551,57 @@ object AutoTradingManager {
} }
} }
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope { private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> =
coroutineScope {
listOf( listOf(
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }, async {
async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, tradeService.fetchMarketRanking(RankingType.VOLUME, true)
async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, .getOrDefault(emptyList())
async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) }, },
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, async {
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) }, tradeService.fetchMarketRanking(RankingType.VOLUME0, true)
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, .getOrDefault(emptyList())
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, },
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }, async {
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) }, tradeService.fetchMarketRanking(RankingType.VOLUME1, true)
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) }, .getOrDefault(emptyList())
async { tradeService.fetchMarketRanking(RankingType.SHORT_SALE, 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() ).awaitAll().flatten()
} }