Compare commits

..
15 Commits
Author SHA1 Message Date
lun_admin 3827698b6f ... 2026-09-15 17:35:11 +09:00
lun_admin c84f4f0cd1 ... 2026-09-15 17:04:57 +09:00
lun_admin a53beb1e18 ... 2026-09-15 16:53:47 +09:00
lun_admin 15793d1852 ... 2026-09-15 16:37:18 +09:00
lun_admin 84f2202f17 ... 2026-09-15 16:17:45 +09:00
lun_admin 1a2fe8b6e6 ..... 2026-09-15 15:35:27 +09:00
lun_admin 0e241205ee ... 2026-09-15 14:29:47 +09:00
lun_admin 8e567f5b69 .... 2026-09-15 14:26:53 +09:00
lun_admin 442cdf0877 .. 2026-09-15 11:37:09 +09:00
lun_admin 3998cf159a .... 2026-09-08 11:30:43 +09:00
lun_admin 5e90ee39bc ... 2026-09-07 14:18:23 +09:00
lun_admin 5882f07e42 ... 2026-08-12 15:22:22 +09:00
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
8 changed files with 885 additions and 371 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, "안전")
}
}
+131 -43
View File
@@ -254,31 +254,61 @@ class TechnicalAnalyzer {
} }
/** /**
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직 * 억울한 HOLD를 막아주는 유연한 과열 판별 로직 (주가 및 변동성 기반 세분화)
*/ */
fun isOverheatedStock(): Boolean { fun isOverheatedStock(): Boolean {
if (daily.size < 20) return false if (daily.size < 20) return false
val currentPrice = daily.last().stck_prpr.toDouble() val currentPrice = daily.last().stck_prpr.toDouble()
// 1. 일봉 20일선 이격도 // 1. 기본 지표 계산
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average() val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
val disparityDaily = (currentPrice / ma20Daily) * 100 val disparityDaily = (currentPrice / ma20Daily) * 100
// 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인)
val rsiDaily = calculateRSI(daily) val rsiDaily = calculateRSI(daily)
// 3. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용) // 2. 가격대별(시가총액 및 호가단위 대용) 동적 임계값 세팅
val maxDisparity: Double // 절대 진입 금지 (초과열)
val warningDisparity: Double // 경고 수준 (보조지표 결합 시 차단)
val microPumpThreshold: Double // 10분 내 단기 펌핑 기준
when {
currentPrice >= 50000.0 -> {
// [대형주/우량주] 무거워서 20일선 대비 15%만 떠도 역사적 과열
maxDisparity = 115.0
warningDisparity = 110.0
microPumpThreshold = 103.0 // 10분 만에 3% 급등하면 꼭지
}
currentPrice in 5000.0..49999.0 -> {
// [일반 중소형주] 표준적인 변동성
maxDisparity = 125.0
warningDisparity = 115.0
microPumpThreshold = 105.0 // 10분 만에 5% 급등
}
else -> {
// [소형주/동전주] 가벼워서 상한가 한 방에 130% 쉽게 도달
maxDisparity = 135.0
warningDisparity = 120.0
microPumpThreshold = 107.0 // 10분 만에 7% 급등
}
}
// 3. 종목 고유 변동성(ATR)을 통한 임계값 미세 보정 (Smart Adjustment)
// 평소 하루에 2~3% 움직이는 얌전한 주식이 갑자기 튀면 임계값을 더 빡빡하게 죔
val atrPct = (calculateATR(daily) / currentPrice) * 100.0
val adjustedMaxDisparity = if (atrPct < 3.0) maxDisparity * 0.96 else maxDisparity
val adjustedWarnDisparity = if (atrPct < 3.0) warningDisparity * 0.97 else warningDisparity
// 4. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용)
var isMicroOverheated = false var isMicroOverheated = false
if (min30.size >= 10) { if (min30.size >= 10) {
val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average() val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average()
val disparityMin = (currentPrice / ma10Min) * 100 val disparityMin = (currentPrice / ma10Min) * 100
isMicroOverheated = disparityMin > 105.0 // 10분 평균가 대비 순간적으로 5% 이상 폭등 시 isMicroOverheated = disparityMin > microPumpThreshold
} }
// 단순히 이격도 115%라고 막는 것이 아니라 3가지 깐깐한 조건 중 하나라도 충족될 때만 과열 판정 // 5. 최종 세분화 판정 로직
return disparityDaily > 130.0 || // (A) 역대급 폭등 상태 return disparityDaily > adjustedMaxDisparity || // (A) 종목 체급 대비 역대급 폭등 상태
(disparityDaily > 115.0 && rsiDaily > 75.0) || // (B) 급등 중이면서 일봉 과매수 (disparityDaily > adjustedWarnDisparity && rsiDaily > 75.0) || // (B) 체급별 경고 수준 + 일봉 과매수
(disparityDaily > 115.0 && isMicroOverheated) // (C) 급등 중이면서 10분 내 순간 펌핑(꼭지) (disparityDaily > adjustedWarnDisparity && isMicroOverheated) // (C) 체급별 경고 수준 + 초단기 펌핑(꼭지)
} }
fun calculateScores(financialScore100: Int): InvestmentScores { fun calculateScores(financialScore100: Int): InvestmentScores {
@@ -322,7 +352,7 @@ class TechnicalAnalyzer {
candles: List<CandleData>, candles: List<CandleData>,
avgReboundTerm: Double, avgReboundTerm: Double,
dropThreshold: Double = 5.0, dropThreshold: Double = 5.0,
timeTolerance: Double = 1.5 timeTolerance: Double = 2.0
): Boolean { ): Boolean {
if (candles.size < 20 || avgReboundTerm <= 0.0) return false if (candles.size < 20 || avgReboundTerm <= 0.0) return false
@@ -343,18 +373,15 @@ class TechnicalAnalyzer {
// 🌟 2. 3가지 핵심 조건 분리 // 🌟 2. 3가지 핵심 조건 분리
val isPriceDropped = currentDropRate <= -dropThreshold val isPriceDropped = currentDropRate <= -dropThreshold
// 조건 A: 가격이 통계적 하락폭만큼 충분히 빠졌는가?
val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance) val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance)
// 조건 B: 반등 '최소' 기간을 채웠는가? (떨어지는 칼날을 너무 일찍 잡는 것 방지)
val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2)) // 최대 기간 조건은 참고용으로 남겨두되 매수 차단 로직에서는 제외합니다.
// 조건 C: 반등 '최대' 기간을 넘기지 않았는가? (죽은 주식처럼 너무 오래 횡보하는 것 방지) // val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2))
// 🌟 3. 3개 중 2개 이상 만족 시 반등 임박(Approaching)으로 판단 // 🌟 3. 현실적인 타점 판별 (필수 2가지만 강력하게 요구)
val passedConditions = listOf(isPriceDropped, isPastMinTime, isWithinMaxTime).count { it } // 필수 1: 가격이 통계적 하락폭만큼 충분히 빠졌는가? (눌림목 대전제)
// 필수 2: 최소한의 반등 준비 기간(평균 기간 - 오차)은 지났는가? (떨어지는 칼날 방지)
return passedConditions >= 2 return isPriceDropped && isPastMinTime
} }
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double { fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
@@ -618,52 +645,113 @@ $standardizedScores
/** /**
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다. * [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
*/ */
/**
* [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
* ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다.
*/
/**
* [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다.
*/
fun predictDropBottom( fun predictDropBottom(
candles: List<CandleData>, candles: List<CandleData>,
reboundStats: ReboundStats, reboundStats: ReboundStats,
volatility: VolatilityForecast volatility: VolatilityForecast,
currentAtr: Double
): DropPrediction? { ): DropPrediction? {
// 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가
if (candles.size < 20 || !reboundStats.isValid) return null if (candles.size < 20 || !reboundStats.isValid) return null
// 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점) // 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외)
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt())) val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
var recentPeakPrice = 0.0
for (i in recentCandles.indices.reversed()) { // 1. 고가 평균점 (Smoothed Peak) 만들기
val highPrice = recentCandles[i].stck_hgpr.toDouble() // 최고가들을 내림차순 정렬하여 상위 3개의 평균을 구함 (비정상적인 윗꼬리 1~2개 무시 효과)
if (highPrice > recentPeakPrice) { val topHighs = recentCandles.map { it.stck_hgpr.toDouble() }.sortedDescending()
recentPeakPrice = highPrice 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() val currentPrice = candles.last().stck_prpr.toDouble()
// 2. 현재까지의 하락률 계산 // 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정
val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락) // 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦
val truePeak = smoothedPeak - (currentAtr * 0.3)
// 3. 1차 예상 바닥가 (과거 평균 하락폭 적용) // 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산
// 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원 val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0
val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0))
// 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?) // 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출
val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0))
val remainingDropRate = reboundStats.avgDropRate - currentDropRate val remainingDropRate = reboundStats.avgDropRate - currentDropRate
// 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때) // 바닥권 인정 마진 (ATR 기반)
val isBottomZone = currentPrice <= (expectedBottomPrice * 1.015) || currentPrice <= (volatility.extremeLow * 1.015) //B. 바닥권 판정 마진predictDropBottom currentAtr * 0.7 여유 마진 마진을 축소(currentAtr * 0.3)하여 예상 바닥에 더 근접해야 인정
val bottomMargin = currentAtr * 0.6
// 저점 평균(smoothedBottom)도 마지노선 계산에 추가 가중치로 섞어줌으로써 저점에 더 밀착시킴
val adjustedExtremeLow = (volatility.extremeLow + smoothedBottom) / 2.0
val isBottomZone = currentPrice <= (expectedBottomPrice + bottomMargin) || currentPrice <= (adjustedExtremeLow + bottomMargin)
return DropPrediction( return DropPrediction(
recentPeakPrice = recentPeakPrice, recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달
expectedBottomPrice = expectedBottomPrice, expectedBottomPrice = expectedBottomPrice,
extremeSupportPrice = volatility.extremeLow, extremeSupportPrice = adjustedExtremeLow, // 보정된 지지선 전달
currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%) currentDropRate = -currentDropRate,
remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻 remainingDropRate = -remainingDropRate,
isBottomZone = isBottomZone 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( data class DropPrediction(
@@ -683,7 +771,7 @@ data class VolatilityForecast(
) )
data class ReboundStats( data class ReboundStats(
val avgReboundPeriod: Double = 0.0, // 평균 반등 소요 캔들 (일/주/월) val avgReboundPeriod: Double = 0.0, // 평균 반등 소요 캔들 (일/주/월)
val timeTolerance: Double = 1.5, // 오차 허용 범위 (표준편차) val timeTolerance: Double = 2.0, // 오차 허용 범위 (표준편차)
val avgDropRate: Double = 5.0, // 평균 하락폭 val avgDropRate: Double = 5.0, // 평균 하락폭
val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가? val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가?
val isValid: Boolean = false val isValid: Boolean = false
+9
View File
@@ -360,4 +360,13 @@ object KisSession {
fun isAvailBuyTime(now: LocalTime) : Boolean { fun isAvailBuyTime(now: LocalTime) : Boolean {
return now.isBefore(endBuyTime()) && now.isAfter(startBuyTime()) return now.isBefore(endBuyTime()) && now.isAfter(startBuyTime())
} }
fun isMarketOpenTime(now: LocalTime) : Boolean {
return now.isBefore(LocalTime.of(15,30)) && now.isAfter(LocalTime.of(9,0))
}
fun isMarketAnalyzerTime(now: LocalTime) : Boolean {
return now.isBefore(LocalTime.of(19,0)) && now.isAfter(LocalTime.of(8,0))
}
} }
+30 -12
View File
@@ -247,6 +247,7 @@ data class ExecutionData(
) )
@Serializable
data class CurrentPriceResponse( data class CurrentPriceResponse(
val rt_cd: String, // 0: 성공, 0 이외: 실패 val rt_cd: String, // 0: 성공, 0 이외: 실패
val msg_cd: String, val msg_cd: String,
@@ -254,19 +255,36 @@ data class CurrentPriceResponse(
val output: CurrentPriceOutput val output: CurrentPriceOutput
) )
@Serializable
data class CurrentPriceOutput( data class CurrentPriceOutput(
val stck_prpr: String, // 주식 현재가 // --- 📊 기존 시세 및 기본 펀더멘털 필드 ---
val prdy_vrss: String, // 전일 대비 val stck_shrn_iscd: String = "", // 종목 코드
val prdy_ctrt: String, // 전일 대비율 val stck_prpr: String = "0", // 주식 현재가
val acml_vol: String, // 누적 거래량 val prdy_vrss: String = "0", // 전일 대비
val stck_oprc: String, // 시가 val prdy_ctrt: String = "0.0", // 전일 대비율
val stck_hgpr: String, // 고가 val acml_vol: String = "0", // 누적 거래량
val stck_lwpr: String, // val stck_oprc: String = "0", //
val hts_avls: String, // 시가총액 val stck_hgpr: String = "0", // 고가
val per: String, val stck_lwpr: String = "0", // 저가
val pbr: String, val hts_avls: String = "0", // 시가총액
val stck_shrn_iscd: String // 종목 코드 val per: String = "0.0",
// ... 필요한 필드가 있다면 Python 모델을 참고하여 추가하세요. val pbr: String = "0.0",
// --- 🚨 신규 추가: 리스크 필터링 및 상태 필드 ---
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 = ""
) )
+1 -1
View File
@@ -45,7 +45,7 @@ class TradingDecision {
var signalModel : ScalpingSignalModel? = null var signalModel : ScalpingSignalModel? = null
var maxRealisticProfitRate :Double = 0.0 var maxRealisticProfitRate :Double = 0.0
var reboundDaysDaily: Double = 0.0 // 일봉 기준 평균 반등 소요일 var reboundDaysDaily: Double = 0.0 // 일봉 기준 평균 반등 소요일
var isWatering: Boolean = false
var reboundWeeksWeekly: Double = 0.0 // 주봉 기준 평균 반등 소요주 var reboundWeeksWeekly: Double = 0.0 // 주봉 기준 평균 반등 소요주
var isReboundApproaching: Boolean = false // 반등 주기에 근접했는지 여부 var isReboundApproaching: Boolean = false // 반등 주기에 근접했는지 여부
var reboundGuideMessage: String = "반등 주기 데이터 없음" // UI나 로그에 노출할 가이드 메시지 var reboundGuideMessage: String = "반등 주기 데이터 없음" // UI나 로그에 노출할 가이드 메시지
+17 -8
View File
@@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonPrimitive
import model.* import model.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@@ -334,7 +335,7 @@ object KisTradeService {
parameter("FID_INPUT_DATE_1", startDate) parameter("FID_INPUT_DATE_1", startDate)
parameter("FID_INPUT_DATE_2", endDate) parameter("FID_INPUT_DATE_2", endDate)
parameter("FID_COND_MRKT_DIV_CODE", "J") parameter("FID_COND_MRKT_DIV_CODE", if(LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) "UN" else "J")
parameter("FID_INPUT_ISCD", stockCode) parameter("FID_INPUT_ISCD", stockCode)
parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M
parameter("FID_ORG_ADJ_PRC", "0") parameter("FID_ORG_ADJ_PRC", "0")
@@ -426,12 +427,14 @@ object KisTradeService {
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U" isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
else -> if (isBuy) "TTTS3002U" else "TTTS3001U" else -> if (isBuy) "TTTS3002U" else "TTTS3001U"
} }
val finalOrderDivision = when { var finalOrderDivision = when {
orderDivision.isNotEmpty() -> orderDivision orderDivision.isNotEmpty() -> orderDivision
marketCode.equals("SOR") || price == "0" || price.isEmpty() -> "01" // 시장가 marketCode.equals("SOR") || price == "0" || price.isEmpty() -> "01" // 시장가
else -> "00" // 지정가 else -> "00" // 지정가
} }
if (marketCode.equals("KRX") && LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) {
finalOrderDivision = "41"
}
return try { return try {
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") { val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
@@ -563,21 +566,27 @@ object KisTradeService {
header("Content-Type", "application/json; charset=utf-8") header("Content-Type", "application/json; charset=utf-8")
// 파라미터 설정 // 파라미터 설정
parameter("FID_COND_MRKT_DIV_CODE", "J") // J: 주식 // parameter("FID_COND_MRKT_DIV_CODE", "J") // J: 주식
parameter("FID_COND_MRKT_DIV_CODE", if(LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) "UN" else "J")
parameter("FID_INPUT_ISCD", stockCode) // 종목코드 parameter("FID_INPUT_ISCD", stockCode) // 종목코드
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val body = response.body<CurrentPriceResponse>() val body = response.body<CurrentPriceResponse>()
if (body.rt_cd == "0") { if (body.rt_cd == "0") {
// println("${body.output}")
Result.success(body.output) Result.success(body.output)
} else { } else {
println("API 에러: ${body.msg1}")
Result.failure(Exception("API 에러: ${body.msg1}")) Result.failure(Exception("API 에러: ${body.msg1}"))
} }
} else { } else {
println("HTTP 에러: ${response.status}")
Result.failure(Exception("HTTP 에러: ${response.status}")) Result.failure(Exception("HTTP 에러: ${response.status}"))
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
println("HTTP 에러: ${e.message}")
Result.failure(e) Result.failure(e)
} }
} }
@@ -679,7 +688,7 @@ object KisTradeService {
try { try {
do { do {
if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가] if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가]
println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)") // println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)")
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer ${config.tradeToken}") header("authorization", "Bearer ${config.tradeToken}")
header("appkey", config.realAppKey) header("appkey", config.realAppKey)
@@ -709,8 +718,8 @@ object KisTradeService {
} }
val body = response.body<StockBalanceResponse>() val body = response.body<StockBalanceResponse>()
println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}\n${body.output2}\n\n") // println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}\n${body.output2}\n\n")
// println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}") println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
allHoldings.addAll(body.output1) allHoldings.addAll(body.output1)
if (totalBalance == null) totalBalance = body if (totalBalance == null) totalBalance = body
@@ -720,7 +729,7 @@ object KisTradeService {
ctxAreaFk = body.ctx_area_fk100 ?: "" ctxAreaFk = body.ctx_area_fk100 ?: ""
ctxAreaNk = body.ctx_area_nk100 ?: "" ctxAreaNk = body.ctx_area_nk100 ?: ""
println("📝 [Header Check] tr_cont: $trCont, ctx_area_nk100: $ctxAreaNk") // println("📝 [Header Check] tr_cont: $trCont, ctx_area_nk100: $ctxAreaNk")
if ( trCont == "M") { if ( trCont == "M") {
pageCount++ pageCount++
+29 -7
View File
@@ -237,7 +237,7 @@ object RagService {
} }
} }
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) { suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, isWatering : Boolean, result: TradingDecisionCallback) {
val totalStartTime = System.currentTimeMillis() val totalStartTime = System.currentTimeMillis()
coroutineScope { coroutineScope {
@@ -246,6 +246,7 @@ object RagService {
this.stockCode = stockCode this.stockCode = stockCode
this.analyzer = technicalAnalyzer this.analyzer = technicalAnalyzer
this.currentPrice = currentPrice this.currentPrice = currentPrice
this.isWatering = isWatering
} }
if (isSafetyBeltStockCodes.contains(stockCode)) { if (isSafetyBeltStockCodes.contains(stockCode)) {
@@ -277,8 +278,10 @@ object RagService {
isSafetyBeltStockCodes.add(stockCode) isSafetyBeltStockCodes.add(stockCode)
return@coroutineScope return@coroutineScope
} }
val techScore = tradingDecision.signalModel?.compositeScore ?: 0
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 40) { // 🌟 신규 매수는 40점 컷오프, 물타기는 20점(또는 제한 없음)으로 하향
val minTechCutoff = if (tradingDecision.isWatering) 15 else 40
if (techScore < minTechCutoff) {
logTime(stockName, "기술 점수 미달 조기 종료 ${tradingDecision.signalModel?.compositeScore} , ${tradingDecision.signalModel?.successProbPct} ", techDuration, System.currentTimeMillis() - totalStartTime) logTime(stockName, "기술 점수 미달 조기 종료 ${tradingDecision.signalModel?.compositeScore} , ${tradingDecision.signalModel?.successProbPct} ", techDuration, System.currentTimeMillis() - totalStartTime)
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) { if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) {
TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true) TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true)
@@ -432,7 +435,7 @@ object RagService {
put("type", "json_object") put("type", "json_object")
} }
}.toString() }.toString()
println("requestBodyJson =>> $requestBodyJson") // println("requestBodyJson =>> $requestBodyJson")
val request = Request.Builder() val request = Request.Builder()
.url(LLM_API_URL()) .url(LLM_API_URL())
.post(requestBodyJson.toRequestBody(jsonMediaType)) .post(requestBodyJson.toRequestBody(jsonMediaType))
@@ -487,9 +490,27 @@ object RagService {
val synthStartTime = System.currentTimeMillis() val synthStartTime = System.currentTimeMillis()
val sysScore100 = calculateSystemPoint(scores) * 4.0 val sysScore100 = calculateSystemPoint(scores) * 4.0
// 가중치 합성 (Tech 35% : Fin 25% : News 20% : Sys 20%) val hasNews = tempDecision.newsContext != null && tempDecision.newsContext!!.isNotBlank()
var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.25) + (newsScore100 * 0.30) + (sysScore100 * 0.20)
// var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.35) + (newsScore100 * 0.20) + (sysScore100 * 0.20) var finalConfidence = if (hasNews) {
// 뉴스가 있을 때: 원래 로직
(finScore100 * 0.25) + (techScore100 * 0.25) + (newsScore100 * 0.30) + (sysScore100 * 0.20)
} else {
// 뉴스가 없을 때: 기술 45%, 재무 35%, 시스템 20%로 배분 (뉴스 영향력 제거)
(finScore100 * 0.35) + (techScore100 * 0.45) + (sysScore100 * 0.20)
}
if (tempDecision.isWatering) {
// 물타기 대상이 여기까지 왔다는 건 '볼린저 하단 터치'나 '초과매도(RSI<35)' 등 바닥 확인이 끝났다는 뜻임.
// 깎인 기술 점수를 보완하기 위해 강한 턴어라운드 기대 가점을 부여
finalConfidence += 20.0
// 물타기 전용 등급 강제 상향 (잡주가 아니라는 전제 하에)
if (finalConfidence >= KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) * 0.8) {
// 물타기 전용 등급(예: 신규 매수 로직에 안 잡히게 LEVEL_3 정도로 고정)
tempDecision.investmentGrade = InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
}
}
// 보너스 및 패널티 로직 // 보너스 및 패널티 로직
if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0 if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0
@@ -569,6 +590,7 @@ object RagService {
this.stockName = stockName this.stockName = stockName
this.currentPrice = tempDecision.currentPrice this.currentPrice = tempDecision.currentPrice
this.techSummary = tempDecision.techSummary this.techSummary = tempDecision.techSummary
this.isWatering = tempDecision.isWatering
this.ultraShortScore = scores.ultraShort.toDouble() this.ultraShortScore = scores.ultraShort.toDouble()
this.shortTermScore = scores.shortTerm.toDouble() this.shortTermScore = scores.shortTerm.toDouble()
this.midTermScore = scores.midTerm.toDouble() this.midTermScore = scores.midTerm.toDouble()
File diff suppressed because it is too large Load Diff