Compare commits

...
3 Commits
Author SHA1 Message Date
lun_admin 302802a53d .... 2026-08-10 17:23:40 +09:00
lun_admin b83eb11cb7 ../. 2026-08-10 11:29:59 +09:00
lun_admin 30ab7bbecf .. 2026-08-10 10:15:13 +09:00
5 changed files with 225 additions and 69 deletions
+50
View File
@@ -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, "안전")
}
}
+82 -22
View File
@@ -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(
+28 -10
View File
@@ -247,6 +247,7 @@ data class ExecutionData(
)
@Serializable
data class CurrentPriceResponse(
val rt_cd: String, // 0: 성공, 0 이외: 실패
val msg_cd: String,
@@ -254,19 +255,36 @@ data class CurrentPriceResponse(
val output: CurrentPriceOutput
)
@Serializable
data class CurrentPriceOutput(
val stck_prpr: String, // 주식 현재가
val prdy_vrss: String, // 전일 대비
val prdy_ctrt: String, // 전일 대비율
val acml_vol: String, // 누적 거래량
val stck_oprc: String, // 시가
val stck_hgpr: String, // 고가
val stck_lwpr: String, //
val hts_avls: String, // 시가총액
// --- 📊 기존 시세 및 기본 펀더멘털 필드 ---
val stck_shrn_iscd: String, // 종목 코드
val stck_prpr: String, // 주식 현재가
val prdy_vrss: String, // 전일 대비
val prdy_ctrt: String, // 전일 대비율
val acml_vol: String, // 누적 거래량
val stck_oprc: String, //
val stck_hgpr: String, // 고가
val stck_lwpr: String, // 저가
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)
}
}
+60 -37
View File
@@ -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,32 +273,30 @@ object AutoTradingManager {
MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
DatabaseFactory.saveAutoTrade(
AutoTradeItem(
orderNo = realOrderNo,
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = effectiveProfitRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
status = "PENDING_BUY",
isDomestic = true
)
)
TradingReportManager.recordTradeDecision(
orderNo = realOrderNo,
stockCode = stockCode,
stockName = stockName,
isBuy = true,
orderQty = inputQty,
reason = decision.reason ?: "", // AI 이유
decision = decision // AI 객체 통째로 전달
)
if (!hasCode) {
DatabaseFactory.saveAutoTrade(
AutoTradeItem(
orderNo = realOrderNo,
code = stockCode,
name = stockName,
quantity = inputQty,
profitRate = effectiveProfitRate,
stopLossRate = sRate,
targetPrice = calculatedTarget,
stopLossPrice = calculatedStop,
status = "PENDING_BUY",
isDomestic = true
)
)
TradingReportManager.recordTradeDecision(
orderNo = realOrderNo,
stockCode = stockCode,
stockName = stockName,
isBuy = true,
orderQty = inputQty,
reason = decision.reason ?: "", // AI 이유
decision = decision // AI 객체 통째로 전달
)
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}")
}
}