This commit is contained in:
2026-02-19 15:47:31 +09:00
parent df124612ab
commit c4f58f159a
11 changed files with 609 additions and 113 deletions
+76 -10
View File
@@ -13,16 +13,17 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import model.CandleData
import model.MAX_BUDGET
import model.MAX_PRICE
import model.MIN_PRICE
import model.ConfigIndex
import model.KisSession
import model.RankingStock
import model.RankingType
import network.DartCodeManager
import network.FinancialMapper
import network.FinancialStatement
import network.KisTradeService
import util.MarketUtil
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
@@ -76,6 +77,40 @@ object AutoTradingManager {
runDiscoveryLoop(tradeService, callback)
}
suspend fun resumePendingSellOrders(tradeService: KisTradeService) {
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
pendingSells.forEach { item ->
// 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치)
val balance = tradeService.fetchIntegratedBalance().getOrNull()
val holding = balance?.holdings?.find { it.code == item.code }
if (holding != null && holding.quantity.toInt() > 0) {
var final = MarketUtil.roundToTickSize(item.targetPrice)
println("🔄 [재주문] ${item.name} (${item.code}) ${item.orderedPrice} ${final} 전날 미체결 매도 건 재주문 시도")
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
tradeService.postOrder(
stockCode = item.code,
qty = item.quantity.toString(),
price = final.toLong().toString(),
isBuy = false
).onSuccess { newOrderNo ->
// 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지
DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo)
println("✅ [재주문 완료] ${item.name}: $newOrderNo")
}.onFailure {
println("❌ [재주문 실패] ${item.name}: ${it.message}")
}
} else {
// 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리
DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED)
}
delay(200) // API 호출 부하 방지
}
}
private fun runDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
discoveryJob = scope.launch {
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
@@ -91,7 +126,7 @@ object AutoTradingManager {
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
//&& now.isBefore(LocalTime.of(15, 30))
if (now.isAfter(LocalTime.of(15, 30)) ) {
executeClosingLiquidation(tradeService)
// executeClosingLiquidation(tradeService)
return@withTimeout
}
@@ -136,7 +171,7 @@ object AutoTradingManager {
iterator.remove()
}
println("남은 후보군 개수 : ${totalCount}")
delay(150)
delay(250)
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
@@ -145,10 +180,10 @@ object AutoTradingManager {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
} catch (e: Exception) {
println("⚠️ [Loop Error] ${e.message}")
delay(5000)
delay(3000)
}
waitForNextCycle(0.5)
waitForNextCycle(0.3)
}
}
}
@@ -164,6 +199,9 @@ object AutoTradingManager {
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(120000L) {
val corpInfo = DartCodeManager.getCorpCode(stock.code)
@@ -185,8 +223,8 @@ object AutoTradingManager {
}
val currentPrice = today.stck_prpr.toDouble()
if (currentPrice > myCash || currentPrice > MAX_BUDGET || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) {
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${MIN_PRICE}, 최대 기준:${MAX_PRICE}] | ")
if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
return@withTimeout
}
@@ -251,7 +289,7 @@ object AutoTradingManager {
while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
println("💤 대기 모드 상태 확인...")
delay(5000)
delay(1000)
}
}
@@ -382,13 +420,41 @@ data class InvestmentScores(
""".trimIndent()
}
}
@Serializable
class TechnicalAnalyzer {
var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
var min30: List<CandleData> = emptyList()
fun isOverheatedStock(): Boolean {
if (min30.size < 20 || daily.size < 20) return false
val currentPrice = min30.last().stck_prpr.toDouble()
// 1. 일봉 기준 이격도 체크 (20일 이평선 대비)
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
val disparityDaily = (currentPrice / ma20Daily) * 100
// 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열)
if (disparityDaily > 125.0) return true
// 2. 분봉(30분봉) 기준 단기 급등 체크
val startPrice30 = min30.first().stck_oprc.toDouble()
val riseRate30 = ((currentPrice - startPrice30) / startPrice30) * 100
// 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험
if (riseRate30 > 15.0) return true
// 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑)
val avgVol = min30.dropLast(3).map { it.cntg_vol.toDouble() }.average()
val recentVol = min30.last().cntg_vol.toDouble()
// 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음
if (recentVol > avgVol * 10) return true
// 4. 볼린저 밴드 상단 이탈 강도
// ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인
return false
}
fun calculateScores(
financialScore: Int // 재무제표 점수 (성장률 등 기반)