atrade/src/main/kotlin/service/AutoTradingManager.kt

467 lines
19 KiB
Kotlin
Raw Normal View History

2026-01-22 16:21:18 +09:00
package service
import TradingDecision
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import model.CandleData
2026-01-22 17:56:31 +09:00
import java.time.LocalDateTime
2026-01-22 16:21:18 +09:00
import java.time.LocalTime
2026-01-22 17:56:31 +09:00
import java.time.ZoneId
import java.time.format.DateTimeFormatter
2026-01-22 16:26:29 +09:00
import kotlin.collections.List
2026-01-22 16:21:18 +09:00
2026-01-22 17:56:31 +09:00
import kotlin.math.*
2026-01-22 16:21:18 +09:00
// service/AutoTradingManager.kt
2026-01-23 17:05:09 +09:00
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
2026-01-22 16:21:18 +09:00
object AutoTradingManager {
private val scope = CoroutineScope(Dispatchers.Default)
2026-01-23 17:05:09 +09:00
val targetStocks = mutableListOf<Pair<String, String>>()
2026-01-22 16:21:18 +09:00
2026-01-23 17:05:09 +09:00
fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) {
targetStocks.add(Pair(stockName, stockCode))
startTradingLoop(stockName,stockCode,result)
2026-01-22 16:21:18 +09:00
}
2026-01-23 17:05:09 +09:00
fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) {
2026-01-22 16:21:18 +09:00
scope.launch {
println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}")
2026-01-23 17:05:09 +09:00
// targetStocks.forEach { stockCode ->
2026-01-22 16:21:18 +09:00
launch { // 종목별 병렬 분석 (M3 Pro 파워 활용)
2026-01-23 17:05:09 +09:00
RagService.processStock(stockName, stockCode,result)
// {decision,b ->
//// when (decision?.decision) {
//// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
//// "SELL" -> executeOrder(stockCode, "매도")
//// else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
//// }
// result(decision,b)
// }
2026-01-22 16:21:18 +09:00
}
2026-01-23 17:05:09 +09:00
// }
// targetStocks.re
// delay(10 * 60 * 1000) // 10분 대기
2026-01-22 16:21:18 +09:00
}
}
private fun executeOrder(code: String, type: String) {
// 실제 증권사 API 호출 로직 (한국투자증권, 키움 등)
println("🔥 [주문 집행] $code $type 완료")
}
}
object TechnicalAnalyzer {
var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
var min30: List<CandleData> = emptyList()
2026-01-22 16:26:29 +09:00
2026-01-22 17:56:31 +09:00
data class InvestmentScores(
val ultraShort: Int, // 초단기 (분봉/에너지)
val shortTerm: Int, // 단기 (일봉/뉴스)
val midTerm: Int, // 중기 (주봉/재무)
val longTerm: Int // 장기 (월봉/펀더멘털)
)
fun calculateScores(
financialScore: Int // 재무제표 점수 (성장률 등 기반)
): InvestmentScores {
// 1. 초단기 (분봉 + 에너지 지표 위주)
val ultra = (TechnicalAnalyzer.calculateMFI(min30, 14) * 0.4 +
TechnicalAnalyzer.calculateStochastic(min30) * 0.3 +
(if(TechnicalAnalyzer.calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt()
// 2. 단기 (일봉 추세 + OBV 에너지)
val short = (TechnicalAnalyzer.calculateRSI(daily) * 0.3 +
(if(TechnicalAnalyzer.calculateOBV(daily) > 0) 40 else 10) +
(if(TechnicalAnalyzer.calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt()
// 3. 중기 (주봉 + 재무 점수 혼합)
val mid = (if(TechnicalAnalyzer.calculateChange(weekly) > 0) 40 else 10) +
(financialScore * 0.6).toInt()
// 4. 장기 (월봉 + 섹터/기업 펀더멘털)
val long = (if(TechnicalAnalyzer.calculateChange(monthly) > 0) 50 else 0) +
(financialScore * 0.5).toInt()
return InvestmentScores(
ultraShort = ultra.coerceIn(0, 100),
shortTerm = short.coerceIn(0, 100),
midTerm = mid.coerceIn(0, 100),
longTerm = long.coerceIn(0, 100)
)
}
2026-01-22 16:21:18 +09:00
fun generateComprehensiveReport(): String {
// [1] 단기 에너지 지표 계산 (최근 30분봉 기준)
val obv = calculateOBV(min30)
val mfi = calculateMFI(min30, 14)
val adLine = calculateADLine(min30)
// [2] 시계열별 가격 변동 및 추세 요약
val m10 = min30.takeLast(10)
val change10 = calculateChange(m10)
val change30 = calculateChange(min30)
val changeDaily = calculateChange(daily.takeLast(2)) // 전일 대비
// [3] 이평선 및 가격 위치
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
val currentPrice = min30.last().stck_prpr.toDouble()
2026-01-23 17:05:09 +09:00
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish())
2026-01-22 16:21:18 +09:00
// [4] 거래량 강도
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0
2026-01-22 17:56:31 +09:00
val atr = calculateATR(min30)
val stochK = calculateStochastic(min30)
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
2026-01-22 16:21:18 +09:00
return """
2026-01-23 17:05:09 +09:00
- /단타 종합 스코어: ${signal.compositeScore} / 100
- /단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
- /단타 성공 확률 예측: ${signal.successProbPct}%
- /단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
- /단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}
- /단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}), 익절가(${signal.suggestedTpPrice.toInt()})
2026-01-22 17:56:31 +09:00
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
- 30 대비: ${ "%.2f".format(change30) }% 변동
- 10 대비: ${ "%.2f".format(change10) }% 변동
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
2026-01-23 17:05:09 +09:00
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) }
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) }
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) }
2026-01-22 17:56:31 +09:00
- 거래량 강도: 최근 5 평균이 30 평균의 ${ "%.1f".format(volStrength) } 수준
2026-01-23 17:05:09 +09:00
- ATR (평균 변동폭): ${"%.0f".format(atr)}
- 30 최대 진폭: ${"%.0f".format(priceRange30)}
- 스토캐스틱(%K): ${"%.1f".format(stochK)}
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)} 수준
2026-01-22 17:56:31 +09:00
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
2026-01-23 17:05:09 +09:00
""".trimIndent()
2026-01-22 16:21:18 +09:00
}
2026-01-22 17:56:31 +09:00
/**
* ATR (Average True Range): 최근 변동 폭의 평균. 그래프의 '출렁임' 크기를 측정
*/
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
val sub = candles.takeLast(period + 1)
val trList = mutableListOf<Double>()
for (i in 1 until sub.size) {
val high = sub[i].stck_hgpr.toDouble()
val low = sub[i].stck_lwpr.toDouble()
val prevClose = sub[i - 1].stck_prpr.toDouble()
val tr = maxOf(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose))
trList.add(tr)
}
return trList.average()
}
/**
* Stochastic (%K): 최근 가격 범위 내에서 현재가의 위치 (0~100)
* 반복되는 파동(Ups and Downs)에서 현재가 고점인지 저점인지 판단
*/
fun calculateStochastic(candles: List<CandleData>, period: Int = 14): Double {
val sub = candles.takeLast(period)
val highest = sub.maxOf { it.stck_hgpr.toDouble() }
val lowest = sub.minOf { it.stck_lwpr.toDouble() }
val current = sub.last().stck_prpr.toDouble()
return if (highest != lowest) (current - lowest) / (highest - lowest) * 100 else 50.0
}
2026-01-22 16:21:18 +09:00
private fun calculateChange(list: List<CandleData>): Double {
val start = list.first().stck_oprc.toDouble()
val end = list.last().stck_prpr.toDouble()
return if (start != 0.0) ((end - start) / start) * 100 else 0.0
}
private fun calculateRSI(list: List<CandleData>): Double {
if (list.size < 2) return 50.0
var gains = 0.0
var losses = 0.0
for (i in 1 until list.size) {
val diff = list[i].stck_prpr.toDouble() - list[i - 1].stck_prpr.toDouble()
if (diff > 0) gains += diff else losses -= diff
}
return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100
}
2026-01-23 17:05:09 +09:00
fun isDailyBullish(): Boolean {
if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
val currentPrice = daily.last().stck_prpr.toDouble()
// 1. MA20 (한 달 생명선) 계산
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
// 2. MA5 (단기 가속도) 계산
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
val isMa5Rising = ma5 > prevMa5
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
return currentPrice > ma20 && isMa5Rising
}
2026-01-22 16:21:18 +09:00
fun calculateOBV(candles: List<CandleData>): Double {
var obv = 0.0
for (i in 1 until candles.size) {
val prevClose = candles[i - 1].stck_prpr.toDouble()
val currClose = candles[i].stck_prpr.toDouble()
val currVol = candles[i].cntg_vol.toDouble()
when {
currClose > prevClose -> obv += currVol
currClose < prevClose -> obv -= currVol
}
}
return obv
}
/**
* MFI (Money Flow Index) 계산 (기간: 보통 14)
*/
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
val subList = candles.takeLast(period + 1)
var posFlow = 0.0
var negFlow = 0.0
for (i in 1 until subList.size) {
val prevTypical = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3
val currTypical = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3
val moneyFlow = currTypical * subList[i].cntg_vol.toDouble()
if (currTypical > prevTypical) posFlow += moneyFlow
else if (currTypical < prevTypical) negFlow += moneyFlow
}
return if (negFlow == 0.0) 100.0 else 100 - (100 / (1+ (posFlow / negFlow)))
}
private fun calculateADLine(candles: List<CandleData>): Double {
var ad = 0.0
candles.forEach {
val high = it.stck_hgpr.toDouble(); val low = it.stck_lwpr.toDouble(); val close = it.stck_prpr.toDouble()
val mfv = if (high != low) ((close - low) - (high - close)) / (high - low) else 0.0
ad += mfv * it.cntg_vol.toDouble()
}
return ad
}
2026-01-22 16:26:29 +09:00
fun clear() {
monthly = emptyList()
weekly = emptyList()
daily = emptyList()
min30 = emptyList()
}
2026-01-22 17:56:31 +09:00
}
class ScalpingAnalyzer {
companion object {
private const val SMA_SHORT = 10
private const val SMA_LONG = 20
private const val RSI_WINDOW = 14
private const val VOL_WINDOW = 20
private const val VOL_SURGE_THRESHOLD = 1.5
private const val RSI_THRESHOLD = 50.0
private const val BB_LOWER_POS = 0.2
private const val BB_UPPER_POS = 0.8
private const val ATR_WINDOW = 14
private const val DEFAULT_SL_PCT = -0.5
private const val DEFAULT_TP_PCT = 1.0
private const val HIGH_SCORE_THRESHOLD = 80
}
fun computeRSI(closes: List<Double>, window: Int = RSI_WINDOW): List<Double> {
val rsi = mutableListOf<Double>()
if (closes.size < window + 1) return rsi
for (i in window until closes.size) {
val gains = mutableListOf<Double>()
val losses = mutableListOf<Double>()
for (j in (i - window + 1) until i + 1) {
val delta = closes[j] - closes[j - 1]
if (delta > 0) gains.add(delta) else losses.add(abs(delta))
}
val avgGain = gains.average()
val avgLoss = losses.average()
val rs = if (avgLoss > 0) avgGain / avgLoss else Double.POSITIVE_INFINITY
rsi.add(100.0 - (100.0 / (1.0 + rs)))
}
return rsi
}
fun bollingerBands(closes: List<Double>, window: Int = SMA_LONG): Triple<List<Double>, List<Double>, List<Double>> {
val sma = mutableListOf<Double>()
val upper = mutableListOf<Double>()
val lower = mutableListOf<Double>()
for (i in window - 1 until closes.size) {
val slice = closes.subList(i - window + 1, i + 1)
val mean = slice.average()
val std = sqrt(slice.map { (it - mean).pow(2.0) }.average()) * 2.0
sma.add(mean)
upper.add(mean + std)
lower.add(mean - std)
}
return Triple(upper, sma, lower)
}
2026-01-23 17:05:09 +09:00
fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
2026-01-22 17:56:31 +09:00
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
val closes = candles.map { it.close }
val volumes = candles.map { it.volume }
// 지표 계산
val sma10 = simpleMovingAverage(closes, SMA_SHORT)
val sma20 = simpleMovingAverage(closes, SMA_LONG)
val rsiList = computeRSI(closes)
val volAvg = simpleMovingAverage(volumes, VOL_WINDOW)
val volRatioList = volumes.mapIndexed { i, v -> if (i >= VOL_WINDOW) v / volAvg[i - VOL_WINDOW] else 0.0 }
val (bbUpper, bbMiddle, bbLower) = bollingerBands(closes)
val current = candles.last()
val idx = candles.size - 1
val currentClose = current.close
val sma10Now = if (sma10.size > 0) sma10.last() else 0.0
val sma20Now = if (sma20.size > 0) sma20.last() else 0.0
val rsiNow = if (rsiList.isNotEmpty()) rsiList.last() else 0.0
val volRatioNow = volRatioList.last()
val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) {
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
} else 0.5
2026-01-23 17:05:09 +09:00
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
val isBreakout = currentClose > nearHigh
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
val bodySize = abs(current.close - current.open)
val lowerShadow = minOf(current.close, current.open) - current.low
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
// 신호 조건 고도화
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
2026-01-22 17:56:31 +09:00
val rsiBull = rsiNow > RSI_THRESHOLD
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD
val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS
2026-01-23 17:05:09 +09:00
val maBull = currentClose > sma10Now && sma10Now > sma20Now
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
// val buySignal = maBull && rsiBull && volSurge && bbGood
2026-01-22 17:56:31 +09:00
2026-01-23 17:05:09 +09:00
val score = (if (maBull) 25 else 0) +
(if (rsiBull) 15 else 0) +
(if (isBreakout) 20 else 0) + // 돌파 에너지 가중치
(minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() +
(if (bbGood) 10 else 0) +
(if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수
2026-01-22 17:56:31 +09:00
// 위험도 (ATR proxy)
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
val atrProxy = if (returns.size >= ATR_WINDOW) {
returns.subList(returns.size - ATR_WINDOW, returns.size).average()
} else 1.0
val riskLevel = when {
abs(atrProxy) < 1 -> "Low"
abs(atrProxy) < 2 -> "Medium"
else -> "High"
}
// 성공 확률 & SL/TP
val successProb = if (buySignal) 75.0 else 35.0 + (score / 100.0 * 20)
val slPrice = currentClose * (1 + DEFAULT_SL_PCT / 100)
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
2026-01-23 17:05:09 +09:00
2026-01-22 17:56:31 +09:00
return ScalpingSignalModel(
currentPrice = currentClose,
buySignal = buySignal,
compositeScore = minOf(score.toInt(), 100),
successProbPct = successProb,
riskLevel = riskLevel,
rsi = rsiNow,
volRatio = volRatioNow,
suggestedSlPrice = slPrice,
suggestedTpPrice = tpPrice,
riskRewardRatio = rrRatio
)
}
private fun simpleMovingAverage(values: List<Double>, window: Int): List<Double> {
val sma = mutableListOf<Double>()
for (i in window - 1 until values.size) {
val slice = values.subList(i - window + 1, i + 1)
sma.add(slice.average())
}
return sma
}
}
data class Candle(
val timestamp: Long,
val open: Double,
val high: Double,
val low: Double,
val close: Double,
val volume: Double
)
data class ScalpingSignalModel(
val currentPrice: Double,
val buySignal: Boolean,
val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수)
val successProbPct: Double, // 성공 확률 추정 %
val riskLevel: String, // "Low", "Medium", "High"
val rsi: Double,
val volRatio: Double,
val suggestedSlPrice: Double, // 손절 가격
val suggestedTpPrice: Double, // 익절 가격
val riskRewardRatio: Double
)
fun CandleData.toScalpingCandle(): Candle {
// 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합
val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}"
val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
// 2. 타임스탬프(Epoch Milliseconds) 계산
val timestamp = try {
val ldt = LocalDateTime.parse(dateTimeStr, formatter)
ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
} catch (e: Exception) {
// 시간 파싱 실패 시 현재 시스템 시간 사용
System.currentTimeMillis()
}
// 3. String 필드들을 Double로 변환하여 Candle 객체 생성
return Candle(
timestamp = timestamp,
open = this.stck_oprc.toDoubleOrNull() ?: 0.0,
high = this.stck_hgpr.toDoubleOrNull() ?: 0.0,
low = this.stck_lwpr.toDoubleOrNull() ?: 0.0,
close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할
volume = this.cntg_vol.toDoubleOrNull() ?: 0.0
)
}
/**
* 리스트 전체를 변환하는 유틸리티
*/
fun List<CandleData>.toScalpingList(): List<Candle> {
return this.map { it.toScalpingCandle() }
}