Compare commits

..
50 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
lun_admin 3fdb298caa ... 2026-08-03 16:17:53 +09:00
lun_admin acd26abe1e .... 2026-08-03 11:53:29 +09:00
lun_admin b134dea1e1 ... 2026-07-10 14:55:19 +09:00
lun_admin 7474558136 ... 2026-06-26 10:17:03 +09:00
lun_admin 81ce9b1539 . 2026-06-19 18:08:15 +09:00
lun_admin c55b089fcd ... 2026-06-19 15:17:20 +09:00
lun_admin fd8283c507 ... 2026-06-19 14:40:46 +09:00
lun_admin ce07537eef ... 2026-06-19 14:31:33 +09:00
lun_admin 8e145803d8 .... 2026-06-18 17:56:34 +09:00
lun_admin 27d330677e ... 2026-06-16 13:07:38 +09:00
lun_admin 71fdabfc32 ... 2026-06-15 14:28:12 +09:00
lun_admin 3fd9e3d833 ..... 2026-06-12 13:11:23 +09:00
lun_admin affad2743e .... 2026-06-09 14:16:18 +09:00
lun_admin 075a085b92 ... 2026-06-08 14:45:15 +09:00
lun_admin 355db7fe20 ... 2026-06-04 11:37:08 +09:00
lun_admin 4a72a30ab6 ... 2026-06-04 11:26:33 +09:00
lun_admin df5febfb42 ... 2026-06-04 10:51:24 +09:00
lun_admin aa4f8daadf ... 2026-06-04 10:42:54 +09:00
lun_admin d57f1698af ... 2026-06-03 13:27:05 +09:00
lun_admin 91b616e127 ... 2026-06-02 17:57:00 +09:00
lun_admin 21242d5ca4 ... 2026-06-02 17:10:06 +09:00
lun_admin d26eb34f1d ... 2026-06-02 15:30:17 +09:00
lun_admin 83d671bece ... 2026-06-02 15:05:02 +09:00
lun_admin 059d1830b7 ... 2026-06-02 14:43:13 +09:00
lun_admin ada0d9d6fe ... 2026-06-02 14:40:33 +09:00
lun_admin 9d43c04670 ... 2026-06-02 11:19:31 +09:00
lun_admin d0bdc57a1e ... 2026-06-02 10:43:37 +09:00
lun_admin 558a39e2d3 ... 2026-06-01 17:55:10 +09:00
lun_admin 510e19b2e8 .... 2026-06-01 13:37:07 +09:00
lun_admin 3fcce5e5c6 .... 2026-05-29 13:29:17 +09:00
lun_admin 74c3e2462d ... 2026-05-27 12:45:19 +09:00
lun_admin ef32260bdb ... 2026-05-26 14:18:41 +09:00
lun_admin 1cca11edc4 .... 2026-05-26 11:20:37 +09:00
lun_admin e94869b9e7 ... 2026-05-20 17:18:15 +09:00
lun_admin ce63b5760a ... 2026-05-19 13:33:59 +09:00
17 changed files with 1921 additions and 462 deletions
+1 -1
View File
@@ -313,7 +313,7 @@ fun main() = application {
AutoTradingManager.isSystemCleanedUpToday = false AutoTradingManager.isSystemCleanedUpToday = false
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
AutoTradingManager.startAutoDiscoveryLoop() AutoTradingManager.startAutoDiscoveryLoop(true)
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
KisWebSocketManager.connect() KisWebSocketManager.connect()
} }
+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, "안전")
}
}
+546 -12
View File
@@ -36,6 +36,83 @@ class TechnicalAnalyzer {
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() } fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
/**
* [신규] 기간별(1M, 6M, 1Y) 최고가 저항선 근접 여부를 판단하여 감점 산출
*/
fun calculateHighPricePenalty(): Double {
if (daily.isEmpty()) return 0.0
val currentPrice = daily.last().stck_prpr.toDouble()
var penalty = 0.0
// 1. 최근 1달 (일봉 20개) 최고가 대비 감점
if (daily.size >= 20) {
val max1M = daily.takeLast(20).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
// 현재가가 1달 최고가를 뚫었거나 최고가의 98% 이상 바짝 붙었을 때 단기 매물대 저항 감점
if (max1M > 0 && currentPrice >= max1M * 0.98) {
penalty -= 3.0
}
}
// 2. 최근 6개월 (주봉 26개) 최고가 대비 감점
if (weekly.size >= 26) {
val max6M = weekly.takeLast(26).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
if (max6M > 0 && currentPrice >= max6M * 0.97) {
penalty -= 4.0
}
}
// 3. 최근 1년 (주봉 52개 또는 월봉 12개) 최고가 대비 감점
if (weekly.size >= 52) {
val max1Y = weekly.takeLast(52).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
if (max1Y > 0 && currentPrice >= max1Y * 0.95) {
penalty -= 5.0
}
} else if (monthly.size >= 12) { // 주봉이 부족할 경우 월봉으로 대체 대안
val max1Y = monthly.takeLast(12).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
if (max1Y > 0 && currentPrice >= max1Y * 0.95) {
penalty -= 5.0
}
}
return penalty
}
/**
* [신규] 단기 낙폭 과대 후 바닥을 다지고 돌아서는 '반등 추세(Turnaround)' 확인 시 가점 산출
*/
fun calculateReboundBonus(): Double {
if (daily.size < 10) return 0.0
// 최근 10일의 데이터를 쪼개어 흐름 분석 (과거 7일 vs 최근 3일)
val past7Days = daily.takeLast(10).take(7)
val recent3Days = daily.takeLast(3)
val pastChange = calculateChange(past7Days) // 이전 7일간의 등락률
val recentChange = calculateChange(recent3Days) // 최근 3일간의 등락률
// 조건: 앞선 7일 동안은 $-3.0\%$ 이하로 밀리며 역배열 혹은 투매가 나왔으나,
// 최근 3일간 $+2.5\%$ 이상 강하게 단기 정배열 전환 혹은 양봉 밀집 반등이 일어날 때
if (pastChange <= -3.0 && recentChange >= 2.5) {
return 8.0 // 반등 성공 가점
}
// 대안 조건: 5일 이동평균선(MA5)의 하락 추세 멈춤 및 상향 턴어라운드(V자 반등) 지점 포착
if (daily.size >= 7) {
val ma5Today = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
val ma5Yesterday = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
val ma5TwoDaysAgo = daily.dropLast(2).takeLast(5).map { it.stck_prpr.toDouble() }.average()
// 2일 전까지는 이평선이 내려앉다가 오늘 고개를 드는 변곡점 형태
if (ma5Today > ma5Yesterday && ma5Yesterday < ma5TwoDaysAgo) {
return 5.0
}
}
return 0.0
}
/** /**
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성 * 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
*/ */
@@ -49,8 +126,18 @@ class TechnicalAnalyzer {
// 2. 점수 정교화 (가점/감점 요인) // 2. 점수 정교화 (가점/감점 요인)
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) { val trendConditions = listOf(
refinedScore += 10.0 calculateChange(monthly) > 0, // 장기 추세
calculateChange(weekly) > 0, // 중기 추세
calculateChange(daily.takeLast(5)) > 0 // 단기 추세
)
val passedCount = trendConditions.count { it == true }
if (passedCount >= 2) {
refinedScore += 10.0 // 2개 이상이 상승 추세면 가점 부여
} else if (passedCount == 3) {
refinedScore += 15.0 // 3개 모두 일치하면 '초강력 추세'로 보너스 추가 가점 (선택 사항)
} }
// [보완] 자금 유입 강도(MFI) 반영 // [보완] 자금 유입 강도(MFI) 반영
@@ -67,6 +154,14 @@ class TechnicalAnalyzer {
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble()) val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
if (bodyRange > atr * 1.2) refinedScore += 7.0 if (bodyRange > atr * 1.2) refinedScore += 7.0
// 🌟 [추가 보완 1] 기간별 최고가 저항선 감점 적용
val highPricePenalty = calculateHighPricePenalty()
refinedScore += highPricePenalty // 음수 값이 반환되므로 가산
// 🌟 [추가 보완 2] 낙폭 과대 후 단기 반등 추세 가점 적용
val reboundBonus = calculateReboundBonus()
refinedScore += reboundBonus
// 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석 // 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석
if (min30.size >= 15) { if (min30.size >= 15) {
val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들) val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들)
@@ -100,31 +195,120 @@ class TechnicalAnalyzer {
} }
/** /**
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직 * [신규] 종목의 평균 반등 텀(캔들 수)을 계산합니다.
* * @param candles 분석할 캔들 리스트 (daily, weekly 등)
* @param dropThreshold 고점 대비 이 비율(%)만큼 떨어지면 하락으로 간주 (기본 5.0%)
* @param reboundThreshold 바닥 대비 이 비율(%)만큼 오르면 반등으로 간주 (기본 3.0%)
* @return 평균 반등에 소요된 캔들 수 (사이클이 없으면 0.0 반환)
*/
fun calculateAverageReboundTerm(
candles: List<CandleData>,
dropThreshold: Double = 5.0,
reboundThreshold: Double = 3.0
): Double {
if (candles.size < 10) return 0.0
var peakPrice = candles.first().stck_hgpr.toDouble()
var bottomPrice = peakPrice
var bottomIndex = 0
var isDropping = false
val reboundTerms = mutableListOf<Int>()
for (i in candles.indices) {
val currentHigh = candles[i].stck_hgpr.toDouble()
val currentLow = candles[i].stck_lwpr.toDouble()
val currentClose = candles[i].stck_prpr.toDouble()
if (!isDropping) {
// 1. 상승/횡보 구간: 고점 갱신 확인
if (currentHigh > peakPrice) {
peakPrice = currentHigh
}
// 고점 대비 특정 비율(dropThreshold) 이상 하락하면 하락장 진입으로 판단
if (peakPrice > 0 && ((currentClose - peakPrice) / peakPrice * 100) <= -dropThreshold) {
isDropping = true
bottomPrice = currentLow
bottomIndex = i // 바닥(최저점) 후보 인덱스 기록
}
} else {
// 2. 하락 구간: 바닥 갱신 확인
if (currentLow < bottomPrice) {
bottomPrice = currentLow
bottomIndex = i
}
// 바닥 대비 특정 비율(reboundThreshold) 이상 상승하면 반등 완료로 판단
if (bottomPrice > 0 && ((currentClose - bottomPrice) / bottomPrice * 100) >= reboundThreshold) {
val daysToRebound = i - bottomIndex // 바닥을 찍고 반등하기까지 걸린 캔들 수
reboundTerms.add(daysToRebound)
// 3. 상태 초기화 (다음 하락/반등 사이클을 찾기 위해)
isDropping = false
peakPrice = currentHigh
}
}
}
// 반등 사이클이 한 번이라도 있었다면 평균 캔들 수를 반환
return if (reboundTerms.isNotEmpty()) reboundTerms.average() else 0.0
}
/**
* 억울한 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 {
@@ -157,6 +341,48 @@ class TechnicalAnalyzer {
} }
return trList.average() return trList.average()
} }
/**
* [신규] 현재 주가가 통계적 반등 주기에 근접했는지 확인합니다.
* @param candles 분석할 캔들 리스트 (daily, weekly 등)
* @param avgReboundTerm 앞서 계산한 평균 반등 소요 캔들 수
* @param dropThreshold 하락장으로 판단할 기준 하락률 (기본 5.0%)
* @param timeTolerance 오차 허용 범위 (기본 1.5 -> 평균 주기보다 하루이틀 빠르거나 늦어도 인정)
*/
fun checkReboundApproaching(
candles: List<CandleData>,
avgReboundTerm: Double,
dropThreshold: Double = 5.0,
timeTolerance: Double = 2.0
): Boolean {
if (candles.size < 20 || avgReboundTerm <= 0.0) return false
// 1. 최근 20일 내 단기 고점 파악
val recentCandles = candles.takeLast(20)
var recentPeakPrice = 0.0
var daysSincePeak = 0
for (i in recentCandles.indices.reversed()) {
val highPrice = recentCandles[i].stck_hgpr.toDouble()
if (highPrice > recentPeakPrice) {
recentPeakPrice = highPrice
daysSincePeak = (recentCandles.size - 1) - i
}
}
val currentDropRate = (candles.last().stck_prpr.toDouble() - recentPeakPrice) / recentPeakPrice * 100
// 🌟 2. 3가지 핵심 조건 분리
val isPriceDropped = currentDropRate <= -dropThreshold
val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance)
// 최대 기간 조건은 참고용으로 남겨두되 매수 차단 로직에서는 제외합니다.
// val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2))
// 🌟 3. 현실적인 타점 판별 (필수 2가지만 강력하게 요구)
// 필수 1: 가격이 통계적 하락폭만큼 충분히 빠졌는가? (눌림목 대전제)
// 필수 2: 최소한의 반등 준비 기간(평균 기간 - 오차)은 지났는가? (떨어지는 칼날 방지)
return isPriceDropped && isPastMinTime
}
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double { fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
if (candles.size < period + 1) return 50.0 if (candles.size < period + 1) return 50.0
@@ -241,4 +467,312 @@ $standardizedScores
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))} - RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
""".trimIndent() """.trimIndent()
} }
/**
* 종목의 과거 차트를 분석하여 고유의 반등 통계(평균 주기, 오차 범위, 평균 하락폭)를 도출합니다.
*/
fun calculateDynamicReboundStats(
candles: List<CandleData>,
minDropToDetect: Double = 3.0
): ReboundStats {
if (candles.size < 20) return ReboundStats()
var peakPrice = candles.first().stck_hgpr.toDouble()
var bottomPrice = peakPrice
var bottomIndex = 0
var isDropping = false
val reboundTerms = mutableListOf<Int>()
val dropRates = mutableListOf<Double>()
val reboundAmplitudes = mutableListOf<Double>() // 🌟 [신규] 반등 상승폭 수집
for (i in candles.indices) {
val currentHigh = candles[i].stck_hgpr.toDouble()
val currentLow = candles[i].stck_lwpr.toDouble()
val currentClose = candles[i].stck_prpr.toDouble()
if (!isDropping) {
if (currentHigh > peakPrice) peakPrice = currentHigh
val dropRate = if (peakPrice > 0) ((currentClose - peakPrice) / peakPrice * 100) else 0.0
if (dropRate <= -minDropToDetect) {
isDropping = true
bottomPrice = currentLow
bottomIndex = i
}
} else {
if (currentLow < bottomPrice) {
bottomPrice = currentLow
bottomIndex = i
}
val reboundRate = if (bottomPrice > 0) ((currentClose - bottomPrice) / bottomPrice * 100) else 0.0
if (reboundRate >= minDropToDetect) { // 3% 이상 반등 시 사이클 종료 및 기록
reboundTerms.add(i - bottomIndex)
dropRates.add(abs((bottomPrice - peakPrice) / peakPrice * 100))
reboundAmplitudes.add(reboundRate) // 🌟 상승폭 기록
isDropping = false
peakPrice = currentHigh
}
}
}
if (reboundTerms.size >= 2) {
val avgDays = reboundTerms.average()
val variance = reboundTerms.map { Math.pow(it - avgDays, 2.0) }.average()
val safeTolerance = Math.sqrt(variance).coerceIn(1.0, 3.0)
return ReboundStats(
avgReboundPeriod = avgDays,
timeTolerance = safeTolerance,
avgDropRate = dropRates.average(),
avgReboundAmplitude = reboundAmplitudes.average(), // 🌟 평균 반등폭 반환
isValid = true
)
}
return ReboundStats()
}
fun generateMTFReboundGuide(
targetProfitRate: Double // 시스템 설정에 있는 목표 수익률 (예: 3.0%)
): String {
// 1. 월, 주, 일봉 통계 추출
val monthlyStats = calculateDynamicReboundStats(monthly, minDropToDetect = 10.0)
val weeklyStats = calculateDynamicReboundStats(weekly, minDropToDetect = 5.0)
val dailyStats = calculateDynamicReboundStats(daily, minDropToDetect = 3.0)
val guideBuilder = java.lang.StringBuilder()
var isVeryFavorable = false
// 2. 가장 신뢰도 높은 '주봉(Weekly)' 기준으로 수익률 보정 평가
if (weeklyStats.isValid) {
// 과거 평균 반등폭이 내 목표 수익률의 1.5배 이상이라면? -> "안전 마진 확보(유리함)"
if (weeklyStats.avgReboundAmplitude >= targetProfitRate * 1.5) {
isVeryFavorable = true
guideBuilder.append("🔥 [프리미엄 타점] 과거 평균 반등폭(${"%.1f".format(weeklyStats.avgReboundAmplitude)}%)이 목표수익률을 크게 상회합니다. 타점을 조금 더 관대하게 잡습니다.\n")
}
// 유리한 조건이면 오차 허용 범위를 넓혀서 예측일에 조금 더 일찍 진입할 수 있게 보정
val adjustedTolerance = if (isVeryFavorable) weeklyStats.timeTolerance * 1.5 else weeklyStats.timeTolerance
guideBuilder.append("- 주간(W): 평균 ${"%.1f".format(weeklyStats.avgReboundPeriod)}주 조정 후 반등 (오차 ±${"%.1f".format(adjustedTolerance)}주)\n")
}
// 3. 일봉 및 월봉 코멘트 추가
if (dailyStats.isValid) {
val dailyAdjTolerance = if (isVeryFavorable) dailyStats.timeTolerance * 1.5 else dailyStats.timeTolerance
guideBuilder.append("- 일간(D): 단기 평균 ${"%.1f".format(dailyStats.avgReboundPeriod)}일 조정 후 반등 (오차 ±${"%.1f".format(dailyAdjTolerance)}일)\n")
}
if (monthlyStats.isValid) {
guideBuilder.append("- 월간(M): 장기 사이클 평균 ${"%.1f".format(monthlyStats.avgReboundPeriod)}개월\n")
}
if (guideBuilder.isEmpty()) {
return "명확한 MTF(다중 타임프레임) 반등 패턴이 없습니다."
}
return guideBuilder.toString()
}
/**
* [신규] 종목이 과열되지 않고 안정적으로 우상향 추세를 타고 있는지 확인합니다.
*/
fun checkSteadyUptrend(candles: List<CandleData>): Boolean {
if (candles.size < 20) return false
val currentPrice = candles.last().stck_prpr.toDouble()
// 1. 이동평균선 계산 (5일, 20일)
val ma5 = candles.takeLast(5).map { it.stck_prpr.toDouble() }.average()
val ma20 = candles.takeLast(20).map { it.stck_prpr.toDouble() }.average()
// 5일 전의 20일 이평선 (20일선 자체가 위로 고개를 들고 있는지 확인)
val pastMa20 = candles.dropLast(5).takeLast(20).map { it.stck_prpr.toDouble() }.average()
// 2. 정배열 및 추세 확인 (현재가 > 5일선 > 20일선)
val isTrendAligned = currentPrice > ma5 && ma5 > ma20
val isMa20Rising = ma20 > pastMa20
// 3. 이격도 과열 방지 (20일선 대비 너무 높게 떠 있으면 추격 매수 금지)
// 기존에 만드신 isOverheatedStock()을 재활용하거나, 여기서 타이트하게 110% 등으로 제어합니다.
val disparity20 = (currentPrice / ma20) * 100
val isNotTooHigh = disparity20 <= 110.0 // 20일선 대비 10% 이내에 있을 때만 안전한 눌림/우상향으로 인정
// 🌟 정배열이고, 20일선이 상승 중이며, 너무 과열되지 않았을 때만 True
return isTrendAligned && isMa20Rising && isNotTooHigh && !isOverheatedStock()
}
/**
* 최근 캔들의 등락률(변동성)을 기반으로 통계적인 다음 캔들의 가격 이동 범위를 예측합니다.
*/
fun calculateVolatilityForecast(candles: List<CandleData>, period: Int = 20): VolatilityForecast {
if (candles.size < period + 1) {
// 데이터가 부족하면 현재가 그대로 반환
val cp = candles.lastOrNull()?.stck_prpr?.toDouble() ?: 0.0
return VolatilityForecast(cp, cp, cp, cp)
}
val currentPrice = candles.last().stck_prpr.toDouble()
val dailyReturns = mutableListOf<Double>()
// 1. 최근 N일간의 등락률(%) 추출
val subList = candles.takeLast(period + 1)
for (i in 1 until subList.size) {
val prevClose = subList[i-1].stck_prpr.toDouble()
val currClose = subList[i].stck_prpr.toDouble()
if (prevClose > 0) {
dailyReturns.add((currClose - prevClose) / prevClose)
}
}
// 2. 등락률의 평균(Mean)과 표준편차(Volatility) 산출
val meanReturn = dailyReturns.average()
val variance = dailyReturns.map { Math.pow(it - meanReturn, 2.0) }.average()
val stdDev = Math.sqrt(variance)
// 3. 현재가에 통계적 변동성(Z-Score)을 곱하여 미래 가격 범위 예측
val realisticHigh = currentPrice * (1 + meanReturn + stdDev)
val realisticLow = currentPrice * (1 + meanReturn - stdDev)
val extremeHigh = currentPrice * (1 + meanReturn + (stdDev * 2))
val extremeLow = currentPrice * (1 + meanReturn - (stdDev * 2))
return VolatilityForecast(realisticHigh, realisticLow, extremeHigh, extremeLow)
}
/**
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
*/
/**
* [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
* ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다.
*/
/**
* [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다.
*/
fun predictDropBottom(
candles: List<CandleData>,
reboundStats: ReboundStats,
volatility: VolatilityForecast,
currentAtr: Double
): DropPrediction? {
if (candles.size < 20 || !reboundStats.isValid) return null
// 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외)
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
// 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 (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()
// 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정
// 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦
val truePeak = smoothedPeak - (currentAtr * 0.3)
// 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산
val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0
// 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출
val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0))
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
// 바닥권 인정 마진 (ATR 기반)
//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(
recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달
expectedBottomPrice = expectedBottomPrice,
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(
val recentPeakPrice: Double, // 최근 단기 고점
val expectedBottomPrice: Double, // 과거 평균 하락률(avgDropRate)을 적용한 1차 예상 바닥가
val extremeSupportPrice: Double, // 2표준편차(extremeLow) 기반의 통계적 2차 마지노선
val currentDropRate: Double, // 단기 고점 대비 현재까지 하락한 비율 (%)
val remainingDropRate: Double, // 1차 예상 바닥까지 남은 추가 하락 여력 (%) - 양수면 더 빠질 공간이 있다는 뜻
val isBottomZone: Boolean // 현재 가격이 바닥권(예상 바닥가의 상하 2% 이내)에 진입했는지 여부
)
data class VolatilityForecast(
val realisticHigh: Double, // 1표준편차 상단 (현실적 목표가, 68% 확률 내)
val realisticLow: Double, // 1표준편차 하단 (현실적 지지선)
val extremeHigh: Double, // 2표준편차 상단 (오버슈팅 저항선, 95% 확률 내)
val extremeLow: Double // 2표준편차 하단 (투매 마지노선)
)
data class ReboundStats(
val avgReboundPeriod: Double = 0.0, // 평균 반등 소요 캔들 (일/주/월)
val timeTolerance: Double = 2.0, // 오차 허용 범위 (표준편차)
val avgDropRate: Double = 5.0, // 평균 하락폭
val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가?
val isValid: Boolean = false
)
+30 -18
View File
@@ -242,7 +242,7 @@ object DatabaseFactory {
fun findAllMonitoringTrades(): List<AutoTradeItem> { fun findAllMonitoringTrades(): List<AutoTradeItem> {
return transaction(mainDb) { return transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.select {
AutoTradeTable.status neq "COMPLETED" AutoTradeTable.status notInList listOf(TradeStatus.COMPLETED, TradeStatus.EXPIRED)
}.map { mapToAutoTradeItem(it) } }.map { mapToAutoTradeItem(it) }
} }
} }
@@ -614,6 +614,22 @@ object TradingLogStore {
} }
fun addNotice(name : String, code : String, log: String) { fun addNotice(name : String, code : String, log: String) {
var isSendable = false
val current = System.currentTimeMillis()
if (KisSession.tradeConfig.useTagsShare.contains("NOTICE") &&
KisSession.tradeConfig.useLogKeywordsShare.any { log.contains(it) }) {
// 대소문자 구분 없이 key를 찾기 위해 원본 code를 가공하거나 그대로 사용
val lastSentTime = noticeFilter[code.uppercase()]
// 기록이 없거나(처음 보냄), 마지막 발송 기준 30분이 지났다면
if (lastSentTime == null || (current - lastSentTime > (1000 * 60) * KisSession.tradeConfig.noticeGapTime)) {
isSendable = true
noticeFilter[code.uppercase()] = current // 즉시 발송 시간 갱신하여 중복 방지
}
}
synchronized(this) { synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0) if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
decisionLogs.add( decisionLogs.add(
@@ -623,28 +639,24 @@ object TradingLogStore {
decision = "NOTICE", decision = "NOTICE",
confidence = 100.0, confidence = 100.0,
reason = log reason = log
).apply { )
if (KisSession.tradeConfig.useTagsShare.contains(decision) && KisSession.tradeConfig.useLogKeywordsShare.any {
log.contains(
it
)
}) {
var current = System.currentTimeMillis()
var sendable = noticeFilter.filter { it.key.equals(code, true) && ((current - it.value) > 1000 * 60 * 30L)}.isNotEmpty()
if (sendable) {
CoroutineScope(Dispatchers.Default).launch {
NewsService.sendTelegramMessage("${this@apply.decision}$name[$code] ${log}")
}}
noticeFilter[code] = current
}
}
) )
} }
if (isSendable) {
applicationScope.launch {
try {
NewsService.sendTelegramMessage("NOTICE $name[$code] $log")
} catch (e: Exception) {
// 발송 실패 시 원상복구를 원한다면 주석 해제 (단, 일시적 네트웍 장애 시 도배 위험 있음)
// noticeFilter.remove(code.uppercase())
e.printStackTrace()
}
}
}
} }
private val applicationScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
var noticeFilter = hashMapOf<String, Long>() var noticeFilter = hashMapOf<String, Long>()
fun addNotice(name : String, code : String, log: String, qty: Int? = null) { fun addNotice(name : String, code : String, log: String, qty: Int? = null) {
synchronized(this) { synchronized(this) {
+23
View File
@@ -254,6 +254,20 @@ class TradeConfig {
var plusFilter : Double = 15.0 var plusFilter : Double = 15.0
var excuteCountOnMin : Int = 2 var excuteCountOnMin : Int = 2
var autoSellOrder : Boolean = false var autoSellOrder : Boolean = false
var excuteMinCheck : Int = 2
var noticeGapTime : Int = 60
var lowerAveragePrice : Boolean = true
var lowerAverageStockCount : Int = 1
var lowerAverageMaxRate : Double = 15.0
var lowerAverageMinRate : Double = 25.0
var lowerAverageTargetCount : Int = 2
var autoSellOrderMin : Double = -15.0
var autoSellOrderMax : Double = -29.0
var autoSellOrderAppend : Int = 3
var minExpectedProfitRate: Double = 2.0 // 필터링 기준 최소 기대 수익률 (%)
var maxExpectedReboundDays: Double = 10.0 // 필터링 기준 최대 허용 반등 주기 (일)
var minExpectedReboundDays: Double = 1.5
var isUpcomingDividend : Boolean = false
} }
@@ -346,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))
}
} }
+15
View File
@@ -38,6 +38,21 @@ cntg_vol : $cntg_vol
acml_tr_pbmn : $acml_tr_pbmn acml_tr_pbmn : $acml_tr_pbmn
""".trimIndent() """.trimIndent()
} }
/**
* 시가 대비 현재가 변동률(%)을 계산하여 반환합니다.
*/
fun getFluctuationRate(): Double {
val openPrice = stck_oprc.toDoubleOrNull() ?: 0.0
val currentPrice = stck_prpr.toDoubleOrNull() ?: 0.0
// 시가가 0이거나 데이터가 없는 경우 0.0 반환 (0으로 나누기 방지)
if (openPrice == 0.0) return 0.0
// 변동률 계산 공식: ((현재가 - 시가) / 시가) * 100
return ((currentPrice - openPrice) / openPrice) * 100
}
} }
@Serializable @Serializable
data class OverseasCandleData( data class OverseasCandleData(
+43 -15
View File
@@ -3,6 +3,8 @@ package model
import AutoTradeItem import AutoTradeItem
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.math.BigDecimal
@Serializable @Serializable
data class StockBalanceResponse( data class StockBalanceResponse(
val rt_cd: String = "", val rt_cd: String = "",
@@ -106,6 +108,14 @@ enum class RankingType(
HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", emptyMap()) HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", emptyMap())
} }
data class UpcomingDividend(
val hasDividend: Boolean,
val stockCode: String,
val stockName: String,
val exDividendDate: String?,
val dividendAmount: BigDecimal?
)
@Serializable @Serializable
data class RankingStock( data class RankingStock(
val hts_kor_isnm: String = "", // 종목명 val hts_kor_isnm: String = "", // 종목명
@@ -118,9 +128,9 @@ data class RankingStock(
val mrkt_div_cls_code : String = "J", val mrkt_div_cls_code : String = "J",
) { ) {
val name : String val name : String
get() = listOf(hts_kor_isnm , hts_kor_alph_nm , mkrtc_objt_iscd).firstOrNull { it.isNotBlank() } ?: "" get() = listOf(hts_kor_isnm , hts_kor_alph_nm).firstOrNull { it.isNotBlank() } ?: ""
val code : String val code : String
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd , hts_kor_isnm).firstOrNull { it.isNotBlank() } ?: "" get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd).firstOrNull { it.isNotBlank() } ?: ""
} }
@Serializable @Serializable
data class OverseasRankingResponse( data class OverseasRankingResponse(
@@ -163,7 +173,7 @@ data class UnifiedStockHolding(
val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt) val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt)
val pchsAmount: String = "0" // 총 매입금액 (pchs_amt) val pchsAmount: String = "0" // 총 매입금액 (pchs_amt)
) { ) {
val isTodayEntry: Boolean get() = thdtBuyQty.toIntOrNull() ?: 0 > 0 val isTodayEntry: Boolean get() = (thdtBuyQty.toIntOrNull() ?: 0) > 0
} }
@Serializable @Serializable
@@ -237,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,
@@ -244,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 = ""
) )
+11 -1
View File
@@ -43,6 +43,12 @@ class TradingDecision {
var financialData : String? = null var financialData : String? = null
var analyzer : TechnicalAnalyzer? = null var analyzer : TechnicalAnalyzer? = null
var signalModel : ScalpingSignalModel? = null var signalModel : ScalpingSignalModel? = null
var maxRealisticProfitRate :Double = 0.0
var reboundDaysDaily: Double = 0.0 // 일봉 기준 평균 반등 소요일
var isWatering: Boolean = false
var reboundWeeksWeekly: Double = 0.0 // 주봉 기준 평균 반등 소요주
var isReboundApproaching: Boolean = false // 반등 주기에 근접했는지 여부
var reboundGuideMessage: String = "반등 주기 데이터 없음" // UI나 로그에 노출할 가이드 메시지
fun shortPossible() = fun shortPossible() =
listOf<Double>(ultraShortScore, listOf<Double>(ultraShortScore,
@@ -60,7 +66,8 @@ class TradingDecision {
longTermScore).average() longTermScore).average()
fun summary() : String{ fun summary(
targetProfitRate: Double) : String{
return """ return """
$corpName[$stockName] $corpName[$stockName]
수익실현 가능성 : ${profitPossible()} 수익실현 가능성 : ${profitPossible()}
@@ -75,6 +82,8 @@ financialScore: $financialScore
newsScore: $newsScore newsScore: $newsScore
decision: $decision decision: $decision
reason: $reason reason: $reason
예측가능 수익율 : ${maxRealisticProfitRate}
반등 주기 가이드: ${analyzer?.generateMTFReboundGuide(targetProfitRate)}
""".trimIndent() """.trimIndent()
} }
@@ -93,6 +102,7 @@ reason: $reason
confidence: $confidence confidence: $confidence
기술 분석: $techSummary 기술 분석: $techSummary
뉴스 점수: $newsScore 뉴스 점수: $newsScore
반등 주기 가이드: $reboundGuideMessage
""".trimIndent() """.trimIndent()
} }
} }
+111 -23
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
@@ -69,7 +70,7 @@ object KisTradeService {
val body = response.body<JsonObject>() val body = response.body<JsonObject>()
// output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일 // output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일
val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y" val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y"
Result.success(!isOpeningDay) Result.success(isOpeningDay)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -122,15 +123,18 @@ object KisTradeService {
} else 0.0 } else 0.0
// 3. 모델 생성 // 3. 모델 생성
Result.success(UnifiedBalance( if (combinedHoldings.isNotEmpty()) {
totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)), Result.success(UnifiedBalance(
deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L), totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)),
dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)), deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L),
todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)), dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)),
totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달 todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)),
holdings = combinedHoldings totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달
)) holdings = combinedHoldings
))
} else {
Result.failure(Exception("combinedHoldings empty"))
}
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -224,6 +228,78 @@ object KisTradeService {
} }
} }
suspend fun fetchUpcomingDividend(
stockCode: String
): Result<UpcomingDividend> {
val config = KisSession.config
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
val fromDate = LocalDate.now().format(formatter)
val toDate = LocalDate.now().plusMonths(6).format(formatter)
return try {
val response = client.get("$prodUrl/uapi/domestic-stock/v1/ksdinfo/dividend") {
header("authorization", "Bearer ${config.marketToken}")
header("appkey", config.realAppKey)
header("appsecret", config.realSecretKey)
header("tr_id", "HHKDB669102C0")
header("custtype", "P")
// 실제 API 문서의 파라미터명으로 변경
parameter("CTS", "")
parameter("GB1", "0")
parameter("SHT_CD", stockCode)
parameter("HIGH_GB", "")
parameter("F_DT", fromDate)
parameter("T_DT", toDate)
}
val body = response.body<JsonObject>()
if (body["rt_cd"]?.jsonPrimitive?.content != "0") {
return Result.failure(
Exception(body["msg1"]?.jsonPrimitive?.content ?: "배당 조회 실패")
)
}
val output = body["output1"]?.jsonArray.orEmpty()
val item = output
.map { it.jsonObject }
.firstOrNull {
it["sht_cd"]?.jsonPrimitive?.content == stockCode
}
println("fetchUpcomingDividend 배당 body >>> ${item}")
if (item == null) {
return Result.success(
UpcomingDividend(
hasDividend = false,
stockCode = stockCode,
stockName = "",
exDividendDate = null,
dividendAmount = null
)
)
}
Result.success(
UpcomingDividend(
hasDividend = true,
stockCode = item["sht_cd"]?.jsonPrimitive?.content.orEmpty(),
stockName = item["isin_name"]?.jsonPrimitive?.content.orEmpty(),
exDividendDate = item["record_date"]?.jsonPrimitive?.content,
dividendAmount = item["per_sto_divi_amt"]?.jsonPrimitive?.content?.toBigDecimalOrNull()
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
/** /**
* [추가] 기간별(일/주/월) 차트 데이터 조회 * [추가] 기간별(일/주/월) 차트 데이터 조회
* @param periodCode "D"(일), "W"(주), "M"(월) * @param periodCode "D"(일), "W"(주), "M"(월)
@@ -234,8 +310,8 @@ object KisTradeService {
isDomestic: Boolean = true isDomestic: Boolean = true
): Result<List<CandleData>> { ): Result<List<CandleData>> {
val config = KisSession.config val config = KisSession.config
val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" val path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice"
val today = LocalDate.now() val today = LocalDate.now()
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd") val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
@@ -244,7 +320,7 @@ object KisTradeService {
// [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전) // [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전)
// 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다. // 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다.
val startDate = when (periodCode) { val startDate = when (periodCode) {
"D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분 "D" -> today.minusDays(90).format(formatter) // 일봉: 6개월치면 100개 충분
"W" -> today.minusYears(2).format(formatter) // 주봉: 2년치 "W" -> today.minusYears(2).format(formatter) // 주봉: 2년치
"M" -> today.minusYears(8).format(formatter) // 월봉: 8년치 "M" -> today.minusYears(8).format(formatter) // 월봉: 8년치
else -> today.minusYears(1).format(formatter) else -> today.minusYears(1).format(formatter)
@@ -259,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")
@@ -351,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") {
@@ -488,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)
} }
} }
@@ -604,11 +688,11 @@ 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", if (config.isSimulation) config.vtsAppKey else config.realAppKey) header("appkey", config.realAppKey)
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey) header("appsecret", config.realSecretKey)
header("tr_id", trId) header("tr_id", trId)
header("tr_cont", trCont) header("tr_cont", trCont)
@@ -627,10 +711,14 @@ object KisTradeService {
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}") println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}")
return Result.failure(Exception("HTTP Error: ${response.status}")) if (allHoldings.isNotEmpty() && totalBalance != null) {
return Result.success(totalBalance.copy(output1 = allHoldings))
}
return Result.failure(Exception("HTTP Error: ${response.status} ${response.body<String>()}"))
} }
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}") println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
allHoldings.addAll(body.output1) allHoldings.addAll(body.output1)
@@ -641,13 +729,13 @@ 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++
trCont = "N" trCont = "N"
println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...") println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...")
delay(250) // API 과부하 방지 delay(500) // API 과부하 방지
} }
} while (trCont == "N") } while (trCont == "N")
@@ -193,7 +193,7 @@ object KisWebSocketManager {
// AES 복호화 실행 // AES 복호화 실행
val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv) val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv)
val dataRows = decryptedData.split("^") val dataRows = decryptedData.split("^")
println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}${if(dataRows[13] == "01"){"체결"}else{"접수"} }") println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}${if(dataRows[13] == "2") {"체결"} else {"접수"} }")
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부) // UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
onExecutionReceived?.invoke( onExecutionReceived?.invoke(
+33 -9
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,11 +246,12 @@ 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)) {
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스 // 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime) println("재무 안정성 부족 (캐시)")
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false) result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
return@coroutineScope return@coroutineScope
} }
@@ -277,9 +278,11 @@ object RagService {
isSafetyBeltStockCodes.add(stockCode) isSafetyBeltStockCodes.add(stockCode)
return@coroutineScope return@coroutineScope
} }
val techScore = tradingDecision.signalModel?.compositeScore ?: 0
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) { // 🌟 신규 매수는 40점 컷오프, 물타기는 20점(또는 제한 없음)으로 하향
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime) val minTechCutoff = if (tradingDecision.isWatering) 15 else 40
if (techScore < minTechCutoff) {
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)
result(tradingDecision.apply { result(tradingDecision.apply {
@@ -331,6 +334,7 @@ object RagService {
result(finalDecision, true) result(finalDecision, true)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
println("❌ [$stockName] 분석 실패: ${e.message}") println("❌ [$stockName] 분석 실패: ${e.message}")
} }
} }
@@ -431,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))
@@ -486,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
@@ -560,6 +582,7 @@ object RagService {
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms") println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
return TradingDecision().apply { return TradingDecision().apply {
this.analyzer = tempDecision.analyzer
this.technicalScore = techScore100 this.technicalScore = techScore100
this.financialScore = finScore100 this.financialScore = finScore100
this.systemScore = sysScore100 this.systemScore = sysScore100
@@ -567,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()
@@ -103,32 +103,6 @@ object LocalReportGenerator {
} }
} }
fun generateAndOpenAsyncDirectly(
summary: RawSummaryData,
rawHoldings: List<RawHoldingData>,
rawTrades: List<RawTradeData>
) {
reportScope.launch {
try {
// 1. [핵심] 대시보드 통계 지표 추출 (Generator가 직접 계산)
val stats = calculateDashboardStats(rawHoldings, rawTrades)
// 2. 탭 2 & 3 HTML 가공
val holdingsHtml = processHoldings(rawHoldings)
val tradesHtml = processTrades(rawTrades)
// 3. 전체 HTML 조립
val htmlContent = buildHtml(summary, stats, holdingsHtml, tradesHtml)
if (summary.type.equals("END", true) || summary.type.equals("MIDDLE", true)) {
saveAndOpen(summary.type, htmlContent)
}
} catch (e: Exception) {
println("❌ [Report] 리포트 비동기 생성 중 오류 발생: ${e.message}")
e.printStackTrace()
}
}
}
// --- [새로운 통계 계산 로직] --- // --- [새로운 통계 계산 로직] ---
private fun calculateDashboardStats(holdings: List<RawHoldingData>, trades: List<RawTradeData>): DashboardStats { private fun calculateDashboardStats(holdings: List<RawHoldingData>, trades: List<RawTradeData>): DashboardStats {
val tradesByStock = trades.groupBy { it.stockCode } val tradesByStock = trades.groupBy { it.stockCode }
@@ -59,11 +59,10 @@ object TradingReportManager : TradingReportService {
private val activePositions = mutableMapOf<String, String>() private val activePositions = mutableMapOf<String, String>()
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) { override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
// if (!KisSession.tradeConfig.useAutoRepost) { if (!KisSession.tradeConfig.useAutoRepost) {
// return return
// } }
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
println("❌ [Report] 리포트 비동기 생성 중 오류 발생: gggg")
val todayDate = LocalDate.now().toString() val todayDate = LocalDate.now().toString()
// 1. 중복 없는 전체 종목 코드 리스트 추출 // 1. 중복 없는 전체 종목 코드 리스트 추출
@@ -233,7 +232,7 @@ object TradingReportManager : TradingReportService {
} }
// 6. 코루틴 기반 제너레이터 호출 // 6. 코루틴 기반 제너레이터 호출
LocalReportGenerator.generateAndOpenAsyncDirectly(summaryData, holdingLogs, tradeLogs) LocalReportGenerator.generateAndOpenAsync(summaryData, holdingLogs, tradeLogs)
} }
} }
} }
File diff suppressed because it is too large Load Diff
+240 -53
View File
@@ -50,13 +50,10 @@ import model.KisSession
import network.KisTradeService import network.KisTradeService
import network.NewsService import network.NewsService
import network.StockUniverseLoader import network.StockUniverseLoader
import report.SnapshotType
import report.TradingReportManager
import service.AutoTradingManager import service.AutoTradingManager
import service.AutoTradingManager.currentBalance
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.time.LocalTime import kotlin.math.abs
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@@ -108,34 +105,14 @@ fun TradingDecisionLog() {
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White)) { Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White)) {
Row(modifier = Modifier.fillMaxWidth(), Button(
horizontalArrangement = Arrangement.spacedBy(4.dp), onClick = {
) { coroutineScope.launch {
Button( // index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0))
onClick = { listState.animateScrollToItem(filteredLogs.size - 1)
coroutineScope.launch {
// index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0))
listState.animateScrollToItem(if (filteredLogs.size - 1 >= 0) filteredLogs.size - 1 else 0)
}
} }
) { Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) } }
) { Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) }
Button(
onClick = {
coroutineScope.launch {
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
currentBalance?.let { currentBalance ->
if (LocalTime.now().isBefore(LocalTime.of(18,1))) {
TradingReportManager.recordAssetSnapshot(
if (LocalTime.now().isAfter(LocalTime.of(18, 0))
) SnapshotType.END else SnapshotType.MIDDLE, currentBalance, ""
)
}
}
}
}
) { Text("Open the report", style = MaterialTheme.typography.body2) }
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
@@ -247,9 +224,9 @@ fun TradingDecisionLog() {
Text( Text(
text = log.decision, text = log.decision,
color = when (log.decision) { color = when (log.decision) {
"BUY" -> Color(0xFF800080) "BUY" -> Color.Red
"SETTING" -> Color(0xFFFFA500) "SETTING" -> Color(0xFFFFA500)
"SELL" -> if (log.reason.contains("손절 처리")) Color.Blue else Color.Red "SELL" -> Color(0xFF800080)
"HOLD" -> Color.DarkGray "HOLD" -> Color.DarkGray
"ANALYZER" -> Color.Green "ANALYZER" -> Color.Green
"PASS" -> Color.Yellow "PASS" -> Color.Yellow
@@ -533,7 +510,28 @@ fun TradingDecisionLog() {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp)) Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "매수 분석 현 변동율 처저 기준",
initialValue = (tradeConfig.minusFilter * -1).toString(),
onSave = {
tradeConfig.minusFilter = abs(it.toDouble())
KisSession.saveTradeConfig()
},
helperText = "현제 변동율이 이것보다 커야 분석 함."
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "매수 분석 현 변동율 최고 기준",
initialValue = (tradeConfig.plusFilter).toString(),
onSave = {
tradeConfig.plusFilter = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "현제 변동율이 이것보다 작아야 분석 함."
)
}
// Boolean 설정들 // Boolean 설정들
SettingSwitchField( SettingSwitchField(
label = "미체결 자동 취소 (매수)", label = "미체결 자동 취소 (매수)",
@@ -566,23 +564,37 @@ fun TradingDecisionLog() {
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소" helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
) )
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingSwitchField (
modifier = Modifier.weight(1.0f, true),
label = "장 전 대체마켓 매도",
initialChecked = tradeConfig.before_nxt,
onCheckedChange = {
tradeConfig.before_nxt = it
KisSession.saveTradeConfig()
}
)
SettingSwitchField(
modifier = Modifier.weight(1.0f, true),
label = "장 후 대체 마켓 매도",
initialChecked = tradeConfig.after_nxt,
onCheckedChange = {
tradeConfig.after_nxt = it
KisSession.saveTradeConfig()
}
)
}
// SettingSwitchField(
// label = "해외 주식",
// initialChecked = tradeConfig.enableOverSea,
// onCheckedChange = { tradeConfig.enableOverSea = it
// KisSession.saveTradeConfig() }
// )
SettingSwitchField( SettingSwitchField(
label = "장 전 대체마켓 매도", label = "배당 주만 거래",
initialChecked = tradeConfig.before_nxt, initialChecked = tradeConfig.isUpcomingDividend,
onCheckedChange = { tradeConfig.before_nxt = it onCheckedChange = { tradeConfig.isUpcomingDividend = it
KisSession.saveTradeConfig() }
)
SettingSwitchField(
label = "장 후 대체 마켓 매도",
initialChecked = tradeConfig.after_nxt,
onCheckedChange = { tradeConfig.after_nxt = it
KisSession.saveTradeConfig() }
)
SettingSwitchField(
label = "해외 주식",
initialChecked = tradeConfig.enableOverSea,
onCheckedChange = { tradeConfig.enableOverSea = it
KisSession.saveTradeConfig() } KisSession.saveTradeConfig() }
) )
@@ -595,6 +607,127 @@ fun TradingDecisionLog() {
}, },
helperText = "본인의 텔레그램 아뒤" helperText = "본인의 텔레그램 아뒤"
) )
SettingSwitchField(
label = "물타기",
initialChecked = tradeConfig.lowerAveragePrice,
onCheckedChange = { tradeConfig.lowerAveragePrice = it
KisSession.saveTradeConfig() }
)
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 최저선",
initialValue = (tradeConfig.lowerAverageMaxRate).toString(),
onSave = {
tradeConfig.lowerAverageMaxRate = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이것보다 커야 삼"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 최고선",
initialValue = (tradeConfig.lowerAverageMinRate).toString(),
onSave = {
tradeConfig.lowerAverageMinRate = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이것보다 작아야 삼"
)
}
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 기준 최소 보유 수량",
initialValue = (tradeConfig.lowerAverageTargetCount).toString(),
onSave = {
tradeConfig.lowerAverageTargetCount = it.toInt()
KisSession.saveTradeConfig()
},
helperText = "이거 이상 갖고 있어야 삼"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "물타기 개수",
initialValue = (tradeConfig.lowerAverageStockCount).toString(),
onSave = {
tradeConfig.lowerAverageStockCount = it.toInt()
KisSession.saveTradeConfig()
},
helperText = "1만큼 사고 팜"
)
}
SettingSwitchField(
label = "아침 자동 매도 주문",
initialChecked = tradeConfig.autoSellOrder,
onCheckedChange = { tradeConfig.autoSellOrder = it
KisSession.saveTradeConfig() }
)
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "자동 매도 기준 최저가",
initialValue = (tradeConfig.autoSellOrderMin).toString(),
onSave = {
tradeConfig.autoSellOrderMin = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이것보다 작아야 주문"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "자동 매도 기준 최고가",
initialValue = (tradeConfig.autoSellOrderMax).toString(),
onSave = {
tradeConfig.autoSellOrderMax = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이것보다 커야 주문"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "매입가 기준 호가위로 주문",
initialValue = (tradeConfig.autoSellOrderAppend).toString(),
onSave = {
tradeConfig.autoSellOrderAppend = it.toInt()
KisSession.saveTradeConfig()
},
helperText = "위의 수치 만큼 호가 위로 주문함."
)
}
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "예상 수익율",
initialValue = (tradeConfig.minExpectedProfitRate).toString(),
onSave = {
tradeConfig.minExpectedProfitRate = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "현제가 기준 예상 수익율이 더커야 삼"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "예상 매도 기준일",
initialValue = (tradeConfig.maxExpectedReboundDays).toString(),
onSave = {
tradeConfig.maxExpectedReboundDays = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "이정도 일수 전에는 팔릴거라 예상"
)
SettingInputField(
modifier = Modifier.weight(1.0f, true),
label = "주식 널뛰기 기준(예상 매도 기준일)",
initialValue = (tradeConfig.minExpectedReboundDays).toString(),
onSave = {
tradeConfig.minExpectedReboundDays = it.toDouble()
KisSession.saveTradeConfig()
},
helperText = "너무 작으면 초단타,널뛰기, 이 보다 커야 분석함"
)
}
} }
} }
@@ -740,9 +873,61 @@ fun CsvDropZone(
} }
//@OptIn(ExperimentalMaterialApi::class)
//@Composable
//fun SettingInputField(
// label: String,
// initialValue: String, // 💡 value -> initialValue 로 변경
// placeholder: String = "",
// helperText: String = "",
// onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경
//) {
// // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
// var localText by remember { mutableStateOf(initialValue) }
//
// Column(modifier = Modifier.fillMaxWidth()) {
// OutlinedTextField(
// value = localText,
// onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
// label = { Text(label, fontWeight = FontWeight.Bold) },
// placeholder = { Text(placeholder) },
// modifier = Modifier
// .fillMaxWidth()
// .onFocusChanged { focusState ->
// // 💡 포커스를 잃었을 때 (다른 칸을 클릭했을 때) 저장
// if (!focusState.isFocused) {
// onSave(localText)
// }
// },
// singleLine = true,
// keyboardOptions = KeyboardOptions(
// imeAction = ImeAction.Done,
// keyboardType = KeyboardType.Decimal
// ),
// keyboardActions = KeyboardActions(
// // 💡 모바일 키보드나 키보드에서 엔터(Done) 쳤을 때 저장
// onDone = {
// onSave(localText)
// }
// )
// )
//
// if (helperText.isNotEmpty()) {
// Spacer(modifier = Modifier.height(4.dp))
// Text(
// text = helperText,
// color = Color.Gray,
// fontSize = 11.sp,
// modifier = Modifier.padding(start = 4.dp)
// )
// }
// }
//}
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun SettingInputField( fun SettingInputField(
modifier: Modifier? = null,
label: String, label: String,
initialValue: String, // 💡 value -> initialValue 로 변경 initialValue: String, // 💡 value -> initialValue 로 변경
placeholder: String = "", placeholder: String = "",
@@ -752,7 +937,7 @@ fun SettingInputField(
// 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책) // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
var localText by remember { mutableStateOf(initialValue) } var localText by remember { mutableStateOf(initialValue) }
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = modifier ?: Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
value = localText, value = localText,
onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영 onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
@@ -791,8 +976,12 @@ fun SettingInputField(
} }
} }
@Composable @Composable
fun SettingSwitchField( fun SettingSwitchField(
modifier :Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 4.dp),
label: String, label: String,
initialChecked: Boolean, initialChecked: Boolean,
helperText: String = "", helperText: String = "",
@@ -802,9 +991,7 @@ fun SettingSwitchField(
var localChecked by remember { mutableStateOf(initialChecked) } var localChecked by remember { mutableStateOf(initialChecked) }
Column( Column(
modifier = Modifier modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 4.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
+24 -20
View File
@@ -6,7 +6,7 @@ import java.time.ZoneId
object MarketUtil { object MarketUtil {
private var isHolidayCached: Boolean? = null // 하루 한 번만 체크하기 위한 캐시 private var isHolidayCached: Boolean? = null // 하루 한 번만 체크하기 위한 캐시
var canTradeDays = hashMapOf<String, Boolean>()
suspend fun canTradeToday(): Boolean { suspend fun canTradeToday(): Boolean {
val seoulZone = java.time.ZoneId.of("Asia/Seoul") val seoulZone = java.time.ZoneId.of("Asia/Seoul")
val now = java.time.ZonedDateTime.now(seoulZone) val now = java.time.ZonedDateTime.now(seoulZone)
@@ -16,25 +16,29 @@ object MarketUtil {
val dayOfWeek = now.dayOfWeek.value val dayOfWeek = now.dayOfWeek.value
if (dayOfWeek >= 6) return false if (dayOfWeek >= 6) return false
// 1. 주말 체크 (토, 일) // 1. 주말 체크 (토, 일)
val cachedHoliday = DatabaseFactory.getHoliday(todayStr) return true
if (cachedHoliday != null) { // try {
println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(cachedHoliday) "휴장" else "영업일"}") // if (canTradeDays.contains(todayStr)) {
return !cachedHoliday // println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}")
} // return canTradeDays.get(todayStr) == true
// }
// 3. DB에 없으면 API 호출 // } catch (e: Exception) {e.printStackTrace()}
return try { //
val result = KisTradeService.fetchIsHoliday(todayStr) //
val isHoliday = result.getOrDefault(true) // // 3. DB에 없으면 API 호출
// return try {
// 결과를 DB에 저장하여 다음 실행 시 재사용 // val result = KisTradeService.fetchIsHoliday(todayStr)
DatabaseFactory.saveHoliday(todayStr, isHoliday) // val canTrade = result.getOrDefault(false)
//
println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다.") // // 결과를 DB에 저장하여 다음 실행 시 재사용
!isHoliday // canTradeDays.put(todayStr, canTrade)
} catch (e: Exception) { //
false // println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다. ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}" )
} // canTrade
// } catch (e: Exception) {
// e.printStackTrace()
// false
// }
} }
fun isKoreanMarketOpen(): Boolean { fun isKoreanMarketOpen(): Boolean {
+16
View File
@@ -11378,5 +11378,21 @@
{ {
"code": "408470", "code": "408470",
"name": "한패스" "name": "한패스"
},
{
"code": "403810",
"name": "아이엘로보틱스"
},
{
"code": "059180",
"name": "엔더블유시"
},
{
"code": "950250",
"name": "테라뷰"
},
{
"code": "288180",
"name": "케이피항공산업"
} }
] ]