This commit is contained in:
lunaticbum 2026-02-24 13:14:11 +09:00
parent 55b82c23af
commit 80a9aa574d
5 changed files with 44 additions and 35 deletions

View File

@ -3,6 +3,8 @@ package network
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import model.RankingStock
import service.AutoTradingManager
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
@ -75,6 +77,7 @@ object DartCodeManager {
// 종목코드(stock_code)가 있는 상장사만 매핑에 추가 // 종목코드(stock_code)가 있는 상장사만 매핑에 추가
if (stockCode.isNotEmpty()) { if (stockCode.isNotEmpty()) {
corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode) corpCodeMap[stockCode] = CorpInfo(corpCode, corpName, stockCode)
// AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode, hts_kor_isnm = corpName))
} }
} }
} }

View File

@ -48,8 +48,8 @@ object AutoTradingManager {
private const val MAX_RISE_RATE = 21.0 private const val MAX_RISE_RATE = 21.0
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분 private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인 private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
private const val STUCK_THRESHOLD = 5 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단 private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
private const val ONE_STOCK_ALYSIS_TIME = 90000L
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>() // 중복 처리 방지용 (선택 사항)
@ -82,32 +82,36 @@ object AutoTradingManager {
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) { suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다. // 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
println("resumePendingSellOrders") println("resumePendingSellOrders")
val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED)) // val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
println("pendingSells >>> ${pendingSells.size}") // println("pendingSells >>> ${pendingSells.size}")
pendingSells.forEach { item -> balance.holdings.forEach { holding ->
// 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치) // 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치)
// val balance = tradeService.fetchIntegratedBalance().getOrNull() // val balance = tradeService.fetchIntegratedBalance().getOrNull()
val holding = balance?.holdings?.find { it.code == item.code } // val holding = balance?.holdings?.find { it.code == item.code }
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > 0.8) {
println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0) {
var final = MarketUtil.roundToTickSize(item.targetPrice)
println("🔄 [재주문] ${item.name} (${item.code}) ${item.orderedPrice} ${final} 전날 미체결 매도 건 재주문 시도")
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송 // 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
var targetPrice = holding.currentPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
tradeService.postOrder( tradeService.postOrder(
stockCode = item.code, stockCode = holding.code,
qty = min(item.quantity,holding.availOrderCount.toInt()).toString(), qty = holding.availOrderCount,
price = final.toLong().toString(), price = targetPrice.toInt().toString(),
isBuy = false isBuy = false
).onSuccess { newOrderNo -> ).onSuccess { newOrderNo ->
// 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지 // 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지
DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo) // DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo)
println("✅ [재주문 완료] ${item.name}: $newOrderNo") println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
}.onFailure { }.onFailure {
println("❌ [재주문 실패] ${item.name}: ${it.message}") println("❌ [재주문 실패] ${holding.name}: ${it.message}")
} }
} else { } else {
// 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리 // 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리
DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED) // DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED)
} }
delay(200) // API 호출 부하 방지 delay(200) // API 호출 부하 방지
} }
@ -127,11 +131,11 @@ object AutoTradingManager {
// [프로세스 1] 장 마감 및 잔고 체크 // [프로세스 1] 장 마감 및 잔고 체크
val now = LocalTime.now(ZoneId.of("Asia/Seoul")) val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
//&& now.isBefore(LocalTime.of(15, 30)) //&& now.isBefore(LocalTime.of(15, 30))
if (now.isAfter(LocalTime.of(15, 15)) ) { if (now.isAfter(LocalTime.of(15, 20)) ) {
executeClosingLiquidation(tradeService) executeClosingLiquidation(tradeService)
return@withTimeout return@withTimeout
} }
// addToReanalysis(RankingStock(mksc_shrn_iscd = ,hts_kor_isnm = ))
val balance = tradeService.fetchIntegratedBalance().getOrNull() val balance = tradeService.fetchIntegratedBalance().getOrNull()
balance?.let { resumePendingSellOrders(tradeService,it) } balance?.let { resumePendingSellOrders(tradeService,it) }
@ -158,7 +162,7 @@ object AutoTradingManager {
.toMutableList() .toMutableList()
if (reanalysisList.isNotEmpty()) { if (reanalysisList.isNotEmpty()) {
candidates.addAll(reanalysisList) candidates.addAll(reanalysisList.asReversed())
} }
reanalysisList.clear() reanalysisList.clear()
remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks }.distinctBy { it.code }) remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks }.distinctBy { it.code })
@ -186,26 +190,25 @@ object AutoTradingManager {
iterator.remove() iterator.remove()
} }
println("남은 후보군 개수 : ${totalCount}") println("남은 후보군 개수 : ${totalCount}")
delay(250) delay(100)
} }
println("⏱️ [Cycle End] ${LocalTime.now()}") println("⏱️ [Cycle End] ${LocalTime.now()}")
} }
} catch (e: TimeoutCancellationException) { } catch (e: TimeoutCancellationException) {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.") println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
} catch (e: Exception) { } catch (e: Exception) {
println("⚠️ [Loop Error] ${e.message}") println("⚠️ [Loop Error] ${e.message}")
delay(3000) delay(1500)
} }
waitForNextCycle(0.3) waitForNextCycle(0.2)
} }
} }
} }
fun addToReanalysis(stock: RankingStock) { fun addToReanalysis(stock: RankingStock) {
val count = retryCountMap.getOrDefault(stock.code, 0) val count = retryCountMap.getOrDefault(stock.code, 0)
if (count < 2) { // 최대 2회까지만 재시도하여 무한 루프 방지 if (count < 10) { // 최대 2회까지만 재시도하여 무한 루프 방지
retryCountMap[stock.code] = count + 1 retryCountMap[stock.code] = count + 1
reanalysisList.add(stock) reanalysisList.add(stock)
println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록") println("📝 [Memory] ${stock.name} 관망 판정 -> 차기 루프 재분석 리스트 등록")
@ -218,7 +221,7 @@ object AutoTradingManager {
val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX)
val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX)
// 개별 종목 분석은 최대 2분으로 제한 // 개별 종목 분석은 최대 2분으로 제한
withTimeout(120000L) { withTimeout(ONE_STOCK_ALYSIS_TIME) {
val corpInfo = DartCodeManager.getCorpCode(stock.code) val corpInfo = DartCodeManager.getCorpCode(stock.code)
if (corpInfo?.cName.isNullOrEmpty()) { if (corpInfo?.cName.isNullOrEmpty()) {
print("-> 기업명을 못찾아서 제외 | ") print("-> 기업명을 못찾아서 제외 | ")
@ -359,8 +362,8 @@ object FinancialAnalyzer {
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만 val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상 val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함 val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
//&& isNotDeficit
return isDebtSafe && isLiquiditySafe && isNotDeficit return isDebtSafe && isLiquiditySafe
} }
/** /**

View File

@ -384,15 +384,13 @@ fun DashboardScreen() {
val saveAction = { val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0 var newValue = localText.toDoubleOrNull() ?: 0.0
// if (configKey.name.contains("PROFIT")) { //
// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
// }
KisSession.config.setValues(configKey, newValue) KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config) DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue") println("💾 저장됨: ${configKey.label} = $newValue")
labelText = if (configKey.name.contains("PROFIT")) { labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}) = ${localText.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues( ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}" ConfigIndex.TAX_INDEX)}"
} else { } else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
@ -401,7 +399,7 @@ fun DashboardScreen() {
labelText = if (configKey.name.contains("PROFIT")) { labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues( getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}) = ${localText.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues( ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)} " ConfigIndex.TAX_INDEX)} "
} else { } else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}" getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"

View File

@ -266,8 +266,8 @@ fun IntegratedOrderSection(
fun resultCheck(completeTradingDecision :TradingDecision) { fun resultCheck(completeTradingDecision :TradingDecision) {
val weights = mapOf( val weights = mapOf(
"short" to 0.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소 "short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
"profit" to 0.3, "profit" to 0.4,
"safe" to 0.4 // 중장기 점수 비중 강화 "safe" to 0.4 // 중장기 점수 비중 강화
) )
@ -322,11 +322,15 @@ fun IntegratedOrderSection(
resultCheck(completeTradingDecision) resultCheck(completeTradingDecision)
} }
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
else -> { "HOLD" -> {
append = 0.0 append = 0.0
resultCheck(completeTradingDecision) resultCheck(completeTradingDecision)
println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}") println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}")
} }
else -> {
append = 0.0
println("[$stockCode] ${completeTradingDecision?.decision} resultCheck: ${completeTradingDecision?.reason}")
}
} }
} }
} }

View File

@ -40,4 +40,5 @@ object MarketUtil {
return Math.round(price / tick) * tick return Math.round(price / tick) * tick
} }
} }