This commit is contained in:
2026-04-17 18:08:53 +09:00
parent ed12d07bc2
commit 2d577300c3
9 changed files with 1022 additions and 478 deletions
+83 -56
View File
@@ -206,19 +206,14 @@ object AutoTradingManager {
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()
// 💡 2. 매수 실행 전, 안전장치 통과 여부 확인
if (!canAddNewPosition(maxStocks)) {
// 제한에 걸렸다면, 매수 로직을 건너뛰고 매도(보유 종목 관리) 로직으로만 넘어갑니다.
println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.")
// UI나 로그에 상태를 띄워주면 좋습니다.
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
@@ -227,14 +222,11 @@ object AutoTradingManager {
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo ->
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
// 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장)
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))
@@ -301,17 +293,18 @@ object AutoTradingManager {
if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) {
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
// 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)
// 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
TradingReportManager.updateExecution(orderNo,finalTargetPrice,dbItem.quantity)
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}")
KisTradeService.postOrder(
stockCode = dbItem.code,
@@ -319,29 +312,33 @@ object AutoTradingManager {
price = finalTargetPrice.toLong().toString(),
isBuy = false
).onSuccess { newSellOrderNo ->
// 익절가 업데이트 및 상태 변경
// 💡 [매도 주문 기록] 이제 팔기 시작했다는 의사결정을 리포트에 남깁니다.
TradingReportManager.recordTradeDecision(
orderNo = newSellOrderNo,
stockCode = dbItem.code,
stockName = dbItem.name,
isBuy = false,
orderQty = dbItem.quantity,
reason = "🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)", // AI 이유
decision = null // AI 객체 통째로 전달
reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)}% 도달을 위한 익절 주문",
holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다!
decision = null
)
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
executionCache.remove(orderNo)
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}")
}
} else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
// ✅ 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) // 사이클 종료 알림
TradingReportManager.updateExecution(orderNo,execData.price.toDouble(),execData.qty.toInt())
TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
}
@@ -375,7 +372,7 @@ object AutoTradingManager {
}
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
balance.getHolldings().forEach { holding ->
balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
@@ -437,6 +434,20 @@ object AutoTradingManager {
}
} 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)
}
}
@@ -453,7 +464,7 @@ object AutoTradingManager {
println("resumePendingSellOrders")
balance.getHolldings().forEach { holding ->
balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer(
@@ -487,6 +498,19 @@ object AutoTradingManager {
"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,
@@ -496,7 +520,21 @@ object AutoTradingManager {
)
}
} else {
analyzeDeepLossHoldingsAfterMarket(holding)
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 , true)
}
delay(200) // API 호출 부하 방지
}
@@ -504,10 +542,10 @@ object AutoTradingManager {
}
}
private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding, isForce : Boolean = false) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
val now = LocalTime.now()
val currentMinute = now.minute
if ((now.hour == 8 || now.hour == 16 || now.hour == 17)) {
if ((!isForce && (now.hour == 8 || now.hour == 16 || now.hour == 17)) || (isForce && (currentMinute % 15 == 0 ))) {
val profit = holding.profitRate.toDouble()
val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다)
if (profit <= lossThreshold) {
@@ -532,10 +570,10 @@ object AutoTradingManager {
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) {
advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
TradingLogStore.addAnalyzer(
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> $advice", true
"수익률($profit%) -> $advice"
)
}
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
@@ -622,6 +660,7 @@ object AutoTradingManager {
val H15M30 = LocalTime.of(15, 30)
val H16 = LocalTime.of(16, 0)
val H18 = LocalTime.of(18, 0)
val H20 = LocalTime.of(20, 0)
val H08M00 = LocalTime.of(8, 0)
val H08M45 = LocalTime.of(8, 45)
val H07M50 = LocalTime.of(7, 50)
@@ -634,10 +673,10 @@ object AutoTradingManager {
currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
when {
now.isAfter(H18) || now.isBefore(H07M50) -> {
now.isAfter(H20) || now.isBefore(H07M50) -> {
prepareMarketOpen(now)
}
now.isBefore(H18) && now.isAfter(H08M00) -> {
now.isBefore(H20) && now.isAfter(H08M00) -> {
waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
@@ -651,7 +690,7 @@ object AutoTradingManager {
}
withTimeout(CYCLE_TIMEOUT) {
println("⏱️ [Cycle Start] ${LocalTime.now()}")
if (now.isAfter(H18)) {
if (now.isAfter(H20)) {
executeClosingLiquidation(KisTradeService)
} else {
executeMarketLoop()
@@ -674,7 +713,7 @@ object AutoTradingManager {
}
suspend fun prepareMarketOpen(now : LocalTime) {
if (now.isAfter(H18) || now.isBefore(H07M50)) {
if (now.isAfter(H20) || now.isBefore(H07M50)) {
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
onMarketClosed?.invoke()
RagService.clearDailyCache()
@@ -717,7 +756,12 @@ object AutoTradingManager {
if (isMorning) {
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
currentBalance?.let { currentBalance ->
TradingReportManager.recordAssetSnapshot(if (LocalTime.now().isAfter(LocalTime.of(17,58))) SnapshotType.END else SnapshotType.MIDDLE ,currentBalance,"")
if (LocalTime.now().isBefore(LocalTime.of(16,2))) {
TradingReportManager.recordAssetSnapshot(
if (LocalTime.now().isAfter(LocalTime.of(17, 59))
) SnapshotType.END else SnapshotType.MIDDLE, currentBalance, ""
)
}
}
if (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
@@ -729,7 +773,7 @@ object AutoTradingManager {
myOredsAndBalanceCodes.clear()
checkBalance()
val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings = currentBalance?.getHolldings()?.map {
val myHoldings = currentBalance?.getHoldings()?.map {
myOredsAndBalanceCodes.add(it.code)
it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
@@ -810,7 +854,7 @@ object AutoTradingManager {
lastForceCheckMinute = currentMinute // 실행 완료 기록
}
}
else if((now.hour == 8 || now.hour == 16 || now.hour == 17 || now.hour == 18 || now.hour == 19) && (currentMinute % 2 == 1)) {
else if((now.hour == 8 || (now.hour >= 16 && now.hour < 20)) && (currentMinute % 2 == 1)) {
if (lastForceCheckMinute != currentMinute) {
TradingLogStore.addAnalyzer(
" - ",
@@ -819,7 +863,7 @@ object AutoTradingManager {
true
)
var list = mutableListOf<String>("X")
if (now.hour != 8) {
if (now.hour != 8 && now.hour < 18) {
list.add("Y")
}
list.forEach { code ->
@@ -901,22 +945,15 @@ object AutoTradingManager {
}
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
listOf(
// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
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.RISE2, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, 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()) },
@@ -948,7 +985,7 @@ object AutoTradingManager {
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
val realHoldings = balanceResult?.getHolldings()?.associateBy { it.code } ?: emptyMap()
val realHoldings = balanceResult?.getHoldings()?.associateBy { it.code } ?: emptyMap()
activeTrades.forEach { trade ->
try {
@@ -988,16 +1025,6 @@ object AutoTradingManager {
}
}
fun checkAndRestart() {
if (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
startAutoDiscoveryLoop()
} else {
}
}
}