Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302802a53d | ||
|
|
b83eb11cb7 | ||
|
|
30ab7bbecf |
@@ -0,0 +1,50 @@
|
||||
package analyzer
|
||||
|
||||
import model.CurrentPriceOutput
|
||||
import model.RiskValidationResult
|
||||
|
||||
object RiskManager {
|
||||
|
||||
/**
|
||||
* 통합된 시세 데이터를 바탕으로 매수 불가능한 '위험 종목'을 걸러냅니다.
|
||||
* @param data API에서 받아온 통합 CurrentPriceOutput 객체
|
||||
* @return RiskValidationResult (isSafe == true 이면 매수 진행)
|
||||
*/
|
||||
fun evaluateRisk(data: CurrentPriceOutput): RiskValidationResult {
|
||||
|
||||
// 1. 거래 정지 및 임시 정지 확인
|
||||
if (data.iscd_stat_cls_code == "58" || data.temp_stop_yn.uppercase() == "Y") {
|
||||
return RiskValidationResult(false, "거래정지 또는 임시정지 종목")
|
||||
}
|
||||
|
||||
// 2. 상장폐지 직전 단계 (정리매매, 관리종목)
|
||||
if (data.sltr_yn.uppercase() == "Y") {
|
||||
return RiskValidationResult(false, "정리매매 진행 중인 종목")
|
||||
}
|
||||
if (data.iscd_stat_cls_code == "51" || data.mang_issu_cls_code == "1" || data.mang_issu_cls_code.uppercase() == "Y") {
|
||||
return RiskValidationResult(false, "상장폐지 위험이 있는 관리종목")
|
||||
}
|
||||
|
||||
// 3. 거래소 경고 (투자위험, 투자경고, 투자주의, 단기과열종목)
|
||||
val warningCodes = listOf("52", "53", "54", "59")
|
||||
if (warningCodes.contains(data.iscd_stat_cls_code)) {
|
||||
return RiskValidationResult(false, "거래소 경고 상태 (상태코드: ${data.iscd_stat_cls_code})")
|
||||
}
|
||||
|
||||
if (data.short_over_yn.uppercase() == "Y") {
|
||||
return RiskValidationResult(false, "단기과열 지정 종목")
|
||||
}
|
||||
|
||||
if (data.invt_caful_yn.uppercase() == "Y") {
|
||||
return RiskValidationResult(false, "투자유의 지정 종목")
|
||||
}
|
||||
|
||||
// 4. 시장경고코드 필터링 (정상 상태인 "00"이나 빈 값이 아닌 경우)
|
||||
if (data.mrkt_warn_cls_code.isNotEmpty() && data.mrkt_warn_cls_code != "00") {
|
||||
return RiskValidationResult(false, "시장경고 발동 중 (경고코드: ${data.mrkt_warn_cls_code})")
|
||||
}
|
||||
|
||||
// 모든 위험 요소를 무사히 통과한 경우
|
||||
return RiskValidationResult(true, "안전")
|
||||
}
|
||||
}
|
||||
@@ -618,52 +618,112 @@ $standardizedScores
|
||||
/**
|
||||
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
||||
*/
|
||||
/**
|
||||
* [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
||||
* ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다.
|
||||
*/
|
||||
/**
|
||||
* [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다.
|
||||
*/
|
||||
fun predictDropBottom(
|
||||
candles: List<CandleData>,
|
||||
reboundStats: ReboundStats,
|
||||
volatility: VolatilityForecast
|
||||
volatility: VolatilityForecast,
|
||||
currentAtr: Double
|
||||
): DropPrediction? {
|
||||
// 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가
|
||||
if (candles.size < 20 || !reboundStats.isValid) return null
|
||||
|
||||
// 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점)
|
||||
// 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외)
|
||||
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
|
||||
var recentPeakPrice = 0.0
|
||||
|
||||
for (i in recentCandles.indices.reversed()) {
|
||||
val highPrice = recentCandles[i].stck_hgpr.toDouble()
|
||||
if (highPrice > recentPeakPrice) {
|
||||
recentPeakPrice = highPrice
|
||||
}
|
||||
// 1. 고가 평균점 (Smoothed Peak) 만들기
|
||||
// 최고가들을 내림차순 정렬하여 상위 3개의 평균을 구함 (비정상적인 윗꼬리 1~2개 무시 효과)
|
||||
val topHighs = recentCandles.map { it.stck_hgpr.toDouble() }.sortedDescending()
|
||||
val smoothedPeak = if (topHighs.size >= 3) {
|
||||
topHighs.take(3).average()
|
||||
} else {
|
||||
topHighs.firstOrNull() ?: 0.0
|
||||
}
|
||||
|
||||
if (recentPeakPrice == 0.0) return null
|
||||
if (smoothedPeak == 0.0) return null
|
||||
|
||||
// 2. 저점 평균점 (Smoothed Bottom) 만들기
|
||||
// 최저가들을 오름차순 정렬하여 하위 3개의 평균을 구함 (순간적인 투매 아랫꼬리 방어)
|
||||
val bottomLows = recentCandles.map { it.stck_lwpr.toDouble() }.sorted()
|
||||
val smoothedBottom = if (bottomLows.size >= 3) {
|
||||
bottomLows.take(3).average()
|
||||
} else {
|
||||
bottomLows.firstOrNull() ?: 0.0
|
||||
}
|
||||
|
||||
val currentPrice = candles.last().stck_prpr.toDouble()
|
||||
|
||||
// 2. 현재까지의 하락률 계산
|
||||
val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락)
|
||||
// 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정
|
||||
// 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦
|
||||
val truePeak = smoothedPeak - (currentAtr * 0.3)
|
||||
|
||||
// 3. 1차 예상 바닥가 (과거 평균 하락폭 적용)
|
||||
// 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원
|
||||
val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0))
|
||||
// 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산
|
||||
val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0
|
||||
|
||||
// 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?)
|
||||
// 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출
|
||||
val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0))
|
||||
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
|
||||
|
||||
// 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때)
|
||||
val isBottomZone = currentPrice <= (expectedBottomPrice * 1.015) || currentPrice <= (volatility.extremeLow * 1.015)
|
||||
// 바닥권 인정 마진 (ATR 기반)
|
||||
val bottomMargin = currentAtr * 0.7
|
||||
|
||||
// 저점 평균(smoothedBottom)도 마지노선 계산에 추가 가중치로 섞어줌으로써 저점에 더 밀착시킴
|
||||
val adjustedExtremeLow = (volatility.extremeLow + smoothedBottom) / 2.0
|
||||
|
||||
val isBottomZone = currentPrice <= (expectedBottomPrice + bottomMargin) || currentPrice <= (adjustedExtremeLow + bottomMargin)
|
||||
|
||||
return DropPrediction(
|
||||
recentPeakPrice = recentPeakPrice,
|
||||
recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달
|
||||
expectedBottomPrice = expectedBottomPrice,
|
||||
extremeSupportPrice = volatility.extremeLow,
|
||||
currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%)
|
||||
remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻
|
||||
extremeSupportPrice = adjustedExtremeLow, // 보정된 지지선 전달
|
||||
currentDropRate = -currentDropRate,
|
||||
remainingDropRate = -remainingDropRate,
|
||||
isBottomZone = isBottomZone
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌟 [신규] 바닥권 도달 시, 하락이 멈추고 지지선이 형성되었는지(Brake) 확인합니다.
|
||||
*/
|
||||
fun checkBrakeAndReversal(candles: List<CandleData>): Boolean {
|
||||
if (candles.size < 3) return false
|
||||
|
||||
val today = candles.last()
|
||||
val yesterday = candles[candles.size - 2]
|
||||
|
||||
val tClose = today.stck_prpr.toDouble()
|
||||
val tOpen = today.stck_oprc.toDouble()
|
||||
val tHigh = today.stck_hgpr.toDouble()
|
||||
val tLow = today.stck_lwpr.toDouble()
|
||||
val tVol = today.cntg_vol.toDouble()
|
||||
|
||||
val yVol = yesterday.cntg_vol.toDouble()
|
||||
|
||||
// 1. 밑꼬리 확인 (망치형 / 도지형)
|
||||
// 몸통(Body) 대비 아래쪽 꼬리(Lower Shadow)가 얼마나 긴가?
|
||||
val body = abs(tClose - tOpen)
|
||||
val lowerShadow = minOf(tClose, tOpen) - tLow
|
||||
val upperShadow = tHigh - maxOf(tClose, tOpen)
|
||||
|
||||
// 꼬리가 몸통보다 1.5배 이상 길고, 윗꼬리보다 아랫꼬리가 더 길면 강력한 누군가의 '매수 개입(지지)'으로 봅니다.
|
||||
val hasLongLowerShadow = (lowerShadow > body * 1.5) && (lowerShadow > upperShadow)
|
||||
|
||||
// 2. 단기 양봉 전환 (하락을 멈추고 고개를 듦)
|
||||
val isBullishBrake = tClose > tOpen && tClose >= yesterday.stck_prpr.toDouble()
|
||||
|
||||
// 3. 투매 진정 (거래량 급감)
|
||||
// 전일 대비 거래량이 눈에 띄게 줄었다는 것은 매도세(던지는 물량)가 말랐다는 뜻입니다.
|
||||
val isVolumeDriedUp = tVol < yVol * 0.7
|
||||
|
||||
// 🌟 지지(밑꼬리)가 나왔거나, 양봉으로 돌렸거나, 던지는 물량이 마른 상태 중 하나라도 충족해야 브레이크가 걸린 것으로 봅니다.
|
||||
return hasLongLowerShadow || isBullishBrake || isVolumeDriedUp
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class DropPrediction(
|
||||
|
||||
@@ -247,6 +247,7 @@ data class ExecutionData(
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class CurrentPriceResponse(
|
||||
val rt_cd: String, // 0: 성공, 0 이외: 실패
|
||||
val msg_cd: String,
|
||||
@@ -254,7 +255,10 @@ data class CurrentPriceResponse(
|
||||
val output: CurrentPriceOutput
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CurrentPriceOutput(
|
||||
// --- 📊 기존 시세 및 기본 펀더멘털 필드 ---
|
||||
val stck_shrn_iscd: String, // 종목 코드
|
||||
val stck_prpr: String, // 주식 현재가
|
||||
val prdy_vrss: String, // 전일 대비
|
||||
val prdy_ctrt: String, // 전일 대비율
|
||||
@@ -265,8 +269,22 @@ data class CurrentPriceOutput(
|
||||
val hts_avls: String, // 시가총액
|
||||
val per: String,
|
||||
val pbr: String,
|
||||
val stck_shrn_iscd: String // 종목 코드
|
||||
// ... 필요한 필드가 있다면 Python 모델을 참고하여 추가하세요.
|
||||
|
||||
// --- 🚨 신규 추가: 리스크 필터링 및 상태 필드 ---
|
||||
val rprs_mrkt_kor_name: String, // 대표 시장 한글 명 (KOSPI, KOSDAQ 등)
|
||||
val iscd_stat_cls_code: String, // 종목 상태 구분 코드 (51:관리, 52:위험, 58:정지 등)
|
||||
val mrkt_warn_cls_code: String, // 시장경고코드 (보통 "00"이 정상)
|
||||
val short_over_yn: String, // 단기과열여부 (Y/N)
|
||||
val sltr_yn: String, // 정리매매여부 (Y/N)
|
||||
val mang_issu_cls_code: String, // 관리종목여부 (1/0 또는 Y/N)
|
||||
val temp_stop_yn: String, // 임시 정지 여부 (Y/N)
|
||||
val invt_caful_yn: String // 투자유의여부 (Y/N)
|
||||
)
|
||||
|
||||
// 필터링 결과를 담을 객체
|
||||
data class RiskValidationResult(
|
||||
val isSafe: Boolean,
|
||||
val rejectReason: String = ""
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -570,14 +570,19 @@ object KisTradeService {
|
||||
if (response.status.isSuccess()) {
|
||||
val body = response.body<CurrentPriceResponse>()
|
||||
if (body.rt_cd == "0") {
|
||||
println("${body.output}")
|
||||
Result.success(body.output)
|
||||
} else {
|
||||
println("API 에러: ${body.msg1}")
|
||||
Result.failure(Exception("API 에러: ${body.msg1}"))
|
||||
}
|
||||
} else {
|
||||
println("HTTP 에러: ${response.status}")
|
||||
Result.failure(Exception("HTTP 에러: ${response.status}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
println("HTTP 에러: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Defines.LLM_PORT
|
||||
import TradingLogStore
|
||||
import TradingLogStore.noticeFilter
|
||||
import analyzer.AdvancedTradeAssistant
|
||||
import analyzer.RiskManager
|
||||
import analyzer.TechnicalAnalyzer
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -272,7 +273,7 @@ object AutoTradingManager {
|
||||
MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
|
||||
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
||||
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
||||
|
||||
if (!hasCode) {
|
||||
DatabaseFactory.saveAutoTrade(
|
||||
AutoTradeItem(
|
||||
orderNo = realOrderNo,
|
||||
@@ -287,7 +288,6 @@ object AutoTradingManager {
|
||||
isDomestic = true
|
||||
)
|
||||
)
|
||||
|
||||
TradingReportManager.recordTradeDecision(
|
||||
orderNo = realOrderNo,
|
||||
stockCode = stockCode,
|
||||
@@ -297,7 +297,6 @@ object AutoTradingManager {
|
||||
reason = decision.reason ?: "", // AI 이유
|
||||
decision = decision // AI 객체 통째로 전달
|
||||
)
|
||||
if (!hasCode) {
|
||||
syncAndExecute(realOrderNo)
|
||||
}
|
||||
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
|
||||
@@ -994,6 +993,7 @@ object AutoTradingManager {
|
||||
currentBalance?.getHoldings()?.map {
|
||||
if(
|
||||
it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount &&
|
||||
it.profitRate.toDouble() < 0.0 &&
|
||||
it.profitRate.toDouble() < (abs(KisSession.tradeConfig.lowerAverageMaxRate) * -1) &&
|
||||
it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1))
|
||||
{
|
||||
@@ -1148,19 +1148,35 @@ object AutoTradingManager {
|
||||
val today = dailyData.lastOrNull() ?: null
|
||||
var rate = today?.getFluctuationRate() ?: 0.0
|
||||
val isOk = ((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1)))
|
||||
println("${stock.name}[${stock.code}] 변동률 : ${rate} , 거래 기준 : ${isOk}")
|
||||
if (today == null) {
|
||||
failList.add(stock.code)
|
||||
delay(50)
|
||||
// 1. var 대신 val을 사용해야 아래에서 스마트 캐스트가 작동하여 !!를 안 써도 됩니다.
|
||||
val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull()
|
||||
|
||||
if (today == null || currentStock == null) {
|
||||
// failList.add(stock.code)
|
||||
print("-> 금일 금액 조회 실패 | ${isOk}")
|
||||
return@withTimeout
|
||||
}
|
||||
val currentPrice = today.stck_prpr.toDouble()
|
||||
// 3. 위에서 확실하게 null 체크를 했으므로, 이제 currentStock은 절대 null이 아닙니다.
|
||||
// 안전하게(Safe call ? 없이) 바로 접근 가능합니다.
|
||||
val currentPrice = currentStock.stck_prpr.toDouble()
|
||||
println("${stock.name}[${stock.code}] 현재가 : ${currentPrice} , 변동률 : ${rate} , 거래 기준 : ${isOk}")
|
||||
|
||||
// 4. 위험한 !! 단언 기호 없이 깔끔하게 호출
|
||||
val riskResult = RiskManager.evaluateRisk(currentStock)
|
||||
if (!riskResult.isSafe) {
|
||||
print("-> ${stock.name}[${stock.code}] 검문소 탈락: ${riskResult.rejectReason}")
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
|
||||
if (!isOk || (myCash > 10L && currentPrice > myCash) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
|
||||
print("-> [${stock.name}] 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 🌟 [추가] 고도화된 사전 필터링 (검문소)
|
||||
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
|
||||
|
||||
@@ -1187,22 +1203,28 @@ object AutoTradingManager {
|
||||
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
|
||||
val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend
|
||||
|
||||
|
||||
val currentAtr = tempAnalyzer.calculateATR(dailyData)
|
||||
if (!isProfitable || !isValidEntryTiming) {
|
||||
print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ")
|
||||
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
|
||||
}
|
||||
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility)
|
||||
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr)
|
||||
|
||||
if (dropPrediction != null) {
|
||||
// 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지!
|
||||
// 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%)
|
||||
if (dropPrediction.remainingDropRate < -1.5 && !dropPrediction.isBottomZone) {
|
||||
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)}% 추가 하락 위험) | ")
|
||||
return@withTimeout // 매수 후보에서 과감히 제외!
|
||||
// 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지
|
||||
if (dropPrediction.remainingDropRate < -2.0 && !dropPrediction.isBottomZone) {
|
||||
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 추가 하락 위험) | ")
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
// 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김
|
||||
// 💡 [방어 로직 2 - 신규] 가격은 바닥권에 왔지만, 캔들에 브레이크(지지)가 걸렸는가?
|
||||
if (dropPrediction.isBottomZone) {
|
||||
val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData)
|
||||
if (!hasBrake) {
|
||||
print("-> [${stock.name}] 바닥 가격 도달했으나, 브레이크(지지/거래량 진정) 미확인. 떨어지는 칼날 회피 | ")
|
||||
return@withTimeout // 브레이크가 없으면 매수 안 함!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (KisSession.tradeConfig.isUpcomingDividend) {
|
||||
@@ -1253,6 +1275,7 @@ object AutoTradingManager {
|
||||
println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
println("❌ [Stock Error] ${stock.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user