Compare commits

..
10 Commits
Author SHA1 Message Date
KJG 488e4e72b3 Merge branch 'master' into g
* master:
  ...

# Conflicts:
#	src/main/kotlin/service/AutoTradingManager.kt
2026-05-19 13:35:00 +09:00
KJG e0fbe9a9a2 Merge branch 'master' into g
* master:
  ...
2026-05-18 13:04:26 +09:00
KJG 74abc6314d Merge branch 'master' into g
* master:
  ...

# Conflicts:
#	src/main/kotlin/service/AutoTradingManager.kt
2026-05-14 14:16:03 +09:00
KJG 92d0a84629 매입단가 손절 2026-05-14 13:29:51 +09:00
KJG 27356f0fc2 Merge branch 'master' into g
* master:
  ...
2026-05-13 16:41:42 +09:00
KJG b1d334a6cd bug fix 2026-05-11 09:57:39 +09:00
KJG 93907c2dac scrolling bug fix 2026-05-08 16:03:54 +09:00
KJG acd1b13760 스케줄로 안뜨게 2026-05-08 13:52:28 +09:00
KJG 81ce68e07b 버튼 눌러서 띄우기 2026-05-08 13:43:56 +09:00
KJG 95f43e105d 판매 로그 > 손절 처리 로그 수정 2026-05-08 10:59:13 +09:00
17 changed files with 358 additions and 1459 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(true) AutoTradingManager.startAutoDiscoveryLoop()
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
KisWebSocketManager.connect() KisWebSocketManager.connect()
} }
-50
View File
@@ -1,50 +0,0 @@
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, "안전")
}
}
+3 -509
View File
@@ -36,83 +36,6 @@ 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) 흐름을 결합한 종합 신호 생성
*/ */
@@ -126,18 +49,8 @@ class TechnicalAnalyzer {
// 2. 점수 정교화 (가점/감점 요인) // 2. 점수 정교화 (가점/감점 요인)
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때 // [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
val trendConditions = listOf( if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
calculateChange(monthly) > 0, // 장기 추세 refinedScore += 10.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) 반영
@@ -154,14 +67,6 @@ 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개 캔들)
@@ -194,65 +99,6 @@ class TechnicalAnalyzer {
) )
} }
/**
* [신규] 종목의 평균 반등 텀(캔들 수)을 계산합니다.
* * @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를 막아주는 유연한 과열 판별 로직 * 억울한 HOLD를 막아주는 유연한 과열 판별 로직
*/ */
@@ -311,51 +157,6 @@ 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 = 1.5
): 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
// 조건 A: 가격이 통계적 하락폭만큼 충분히 빠졌는가?
val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance)
// 조건 B: 반등 '최소' 기간을 채웠는가? (떨어지는 칼날을 너무 일찍 잡는 것 방지)
val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2))
// 조건 C: 반등 '최대' 기간을 넘기지 않았는가? (죽은 주식처럼 너무 오래 횡보하는 것 방지)
// 🌟 3. 3개 중 2개 이상 만족 시 반등 임박(Approaching)으로 판단
val passedConditions = listOf(isPriceDropped, isPastMinTime, isWithinMaxTime).count { it }
return passedConditions >= 2
}
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
@@ -440,311 +241,4 @@ $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 기반)
val bottomMargin = currentAtr * 0.7
// 저점 평균(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 = 1.5, // 오차 허용 범위 (표준편차)
val avgDropRate: Double = 5.0, // 평균 하락폭
val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가?
val isValid: Boolean = false
)
+18 -30
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 notInList listOf(TradeStatus.COMPLETED, TradeStatus.EXPIRED) AutoTradeTable.status neq "COMPLETED"
}.map { mapToAutoTradeItem(it) } }.map { mapToAutoTradeItem(it) }
} }
} }
@@ -614,22 +614,6 @@ 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(
@@ -639,24 +623,28 @@ 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) {
-14
View File
@@ -254,20 +254,6 @@ 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
} }
-15
View File
@@ -38,21 +38,6 @@ 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(
+13 -41
View File
@@ -3,8 +3,6 @@ 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 = "",
@@ -108,14 +106,6 @@ 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 = "", // 종목명
@@ -128,9 +118,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).firstOrNull { it.isNotBlank() } ?: "" get() = listOf(hts_kor_isnm , hts_kor_alph_nm , mkrtc_objt_iscd).firstOrNull { it.isNotBlank() } ?: ""
val code : String val code : String
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd).firstOrNull { it.isNotBlank() } ?: "" get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd , hts_kor_isnm).firstOrNull { it.isNotBlank() } ?: ""
} }
@Serializable @Serializable
data class OverseasRankingResponse( data class OverseasRankingResponse(
@@ -173,7 +163,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
@@ -247,7 +237,6 @@ 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,
@@ -255,36 +244,19 @@ data class CurrentPriceResponse(
val output: CurrentPriceOutput val output: CurrentPriceOutput
) )
@Serializable
data class CurrentPriceOutput( data class CurrentPriceOutput(
// --- 📊 기존 시세 및 기본 펀더멘털 필드 --- val stck_prpr: String, // 주식 현재가
val stck_shrn_iscd: String, // 종목 코드 val prdy_vrss: String, // 전일 대비
val stck_prpr: String, // 주식 현재가 val prdy_ctrt: String, // 전일 대비율
val prdy_vrss: String, // 전일 대비 val acml_vol: String, // 누적 거래량
val prdy_ctrt: String, // 전일 대비율 val stck_oprc: String, // 시가
val acml_vol: String, // 누적 거래량 val stck_hgpr: String, // 고가
val stck_oprc: String, // val stck_lwpr: String, //
val stck_hgpr: String, // 고가 val hts_avls: String, // 시가총액
val stck_lwpr: String, // 저가
val hts_avls: String, // 시가총액
val per: String, val per: String,
val pbr: String, val pbr: String,
val stck_shrn_iscd: String // 종목 코드
// --- 🚨 신규 추가: 리스크 필터링 및 상태 필드 --- // ... 필요한 필드가 있다면 Python 모델을 참고하여 추가하세요.
val rprs_mrkt_kor_name: String, // 대표 시장 한글 명 (KOSPI, KOSDAQ 등)
val iscd_stat_cls_code: String, // 종목 상태 구분 코드 (51:관리, 52:위험, 58:정지 등)
val mrkt_warn_cls_code: String, // 시장경고코드 (보통 "00"이 정상)
val short_over_yn: String, // 단기과열여부 (Y/N)
val sltr_yn: String, // 정리매매여부 (Y/N)
val mang_issu_cls_code: String, // 관리종목여부 (1/0 또는 Y/N)
val temp_stop_yn: String, // 임시 정지 여부 (Y/N)
val invt_caful_yn: String // 투자유의여부 (Y/N)
)
// 필터링 결과를 담을 객체
data class RiskValidationResult(
val isSafe: Boolean,
val rejectReason: String = ""
) )
+1 -11
View File
@@ -43,12 +43,6 @@ 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 reboundWeeksWeekly: Double = 0.0 // 주봉 기준 평균 반등 소요주
var isReboundApproaching: Boolean = false // 반등 주기에 근접했는지 여부
var reboundGuideMessage: String = "반등 주기 데이터 없음" // UI나 로그에 노출할 가이드 메시지
fun shortPossible() = fun shortPossible() =
listOf<Double>(ultraShortScore, listOf<Double>(ultraShortScore,
@@ -66,8 +60,7 @@ class TradingDecision {
longTermScore).average() longTermScore).average()
fun summary( fun summary() : String{
targetProfitRate: Double) : String{
return """ return """
$corpName[$stockName] $corpName[$stockName]
수익실현 가능성 : ${profitPossible()} 수익실현 가능성 : ${profitPossible()}
@@ -82,8 +75,6 @@ financialScore: $financialScore
newsScore: $newsScore newsScore: $newsScore
decision: $decision decision: $decision
reason: $reason reason: $reason
예측가능 수익율 : ${maxRealisticProfitRate}
반등 주기 가이드: ${analyzer?.generateMTFReboundGuide(targetProfitRate)}
""".trimIndent() """.trimIndent()
} }
@@ -102,7 +93,6 @@ reason: $reason
confidence: $confidence confidence: $confidence
기술 분석: $techSummary 기술 분석: $techSummary
뉴스 점수: $newsScore 뉴스 점수: $newsScore
반등 주기 가이드: $reboundGuideMessage
""".trimIndent() """.trimIndent()
} }
} }
+18 -102
View File
@@ -69,7 +69,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,18 +122,15 @@ object KisTradeService {
} else 0.0 } else 0.0
// 3. 모델 생성 // 3. 모델 생성
if (combinedHoldings.isNotEmpty()) { Result.success(UnifiedBalance(
Result.success(UnifiedBalance( totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)),
totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)), deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L),
deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L), dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)),
dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)), todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)),
todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)), totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달
totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달 holdings = combinedHoldings
holdings = combinedHoldings ))
))
} else {
Result.failure(Exception("combinedHoldings empty"))
}
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -227,78 +224,6 @@ 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"()
@@ -309,8 +234,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 = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" val path = if (isDomestic) "/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")
@@ -319,7 +244,7 @@ object KisTradeService {
// [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전) // [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전)
// 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다. // 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다.
val startDate = when (periodCode) { val startDate = when (periodCode) {
"D" -> today.minusDays(90).format(formatter) // 일봉: 6개월치면 100개 충분 "D" -> today.minusMonths(6).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)
@@ -570,19 +495,14 @@ object KisTradeService {
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)
} }
} }
@@ -687,8 +607,8 @@ object KisTradeService {
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", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
header("appsecret", config.realSecretKey) header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
header("tr_id", trId) header("tr_id", trId)
header("tr_cont", trCont) header("tr_cont", trCont)
@@ -707,15 +627,11 @@ object KisTradeService {
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}") println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}")
if (allHoldings.isNotEmpty() && totalBalance != null) { return Result.failure(Exception("HTTP Error: ${response.status}"))
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)
if (totalBalance == null) totalBalance = body if (totalBalance == null) totalBalance = body
@@ -731,7 +647,7 @@ object KisTradeService {
pageCount++ pageCount++
trCont = "N" trCont = "N"
println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...") println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...")
delay(500) // API 과부하 방지 delay(250) // 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] == "2") {"체결"} else {"접수"} }") println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}${if(dataRows[13] == "01"){"체결"}else{"접수"} }")
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부) // UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
onExecutionReceived?.invoke( onExecutionReceived?.invoke(
+3 -5
View File
@@ -250,7 +250,7 @@ object RagService {
if (isSafetyBeltStockCodes.contains(stockCode)) { if (isSafetyBeltStockCodes.contains(stockCode)) {
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스 // 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
println("재무 안정성 부족 (캐시)") // logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false) result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
return@coroutineScope return@coroutineScope
} }
@@ -278,8 +278,8 @@ object RagService {
return@coroutineScope return@coroutineScope
} }
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 40) { if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
logTime(stockName, "기술 점수 미달 조기 종료 ${tradingDecision.signalModel?.compositeScore} , ${tradingDecision.signalModel?.successProbPct} ", techDuration, System.currentTimeMillis() - totalStartTime) logTime(stockName, "기술 점수 미달 조기 종료", 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,7 +331,6 @@ object RagService {
result(finalDecision, true) result(finalDecision, true)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
println("❌ [$stockName] 분석 실패: ${e.message}") println("❌ [$stockName] 분석 실패: ${e.message}")
} }
} }
@@ -561,7 +560,6 @@ 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
@@ -103,6 +103,32 @@ 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,10 +59,11 @@ 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. 중복 없는 전체 종목 코드 리스트 추출
@@ -232,7 +233,7 @@ object TradingReportManager : TradingReportService {
} }
// 6. 코루틴 기반 제너레이터 호출 // 6. 코루틴 기반 제너레이터 호출
LocalReportGenerator.generateAndOpenAsync(summaryData, holdingLogs, tradeLogs) LocalReportGenerator.generateAndOpenAsyncDirectly(summaryData, holdingLogs, tradeLogs)
} }
} }
} }
+196 -396
View File
@@ -1,13 +1,12 @@
package service package service
import AutoTradeItem import AutoTradeItem
import Defines.AUTOSELL
import Defines.BLACKLISTEDSTOCKCODES import Defines.BLACKLISTEDSTOCKCODES
import Defines.EMBEDDING_PORT import Defines.EMBEDDING_PORT
import Defines.LLM_PORT import Defines.LLM_PORT
import TradingLogStore import TradingLogStore
import TradingLogStore.noticeFilter
import analyzer.AdvancedTradeAssistant import analyzer.AdvancedTradeAssistant
import analyzer.RiskManager
import analyzer.TechnicalAnalyzer import analyzer.TechnicalAnalyzer
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -38,9 +37,8 @@ import network.KisAuthService
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import network.RagService import network.RagService
import network.RagService.isSafetyBeltStockCodes
import network.StockUniverseLoader import network.StockUniverseLoader
import okhttp3.internal.wait import report.SnapshotType
import report.TradingReportManager import report.TradingReportManager
import util.MarketUtil import util.MarketUtil
import java.time.LocalDate import java.time.LocalDate
@@ -50,8 +48,6 @@ import java.time.ZoneId
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.collections.List import kotlin.collections.List
import kotlin.collections.filter import kotlin.collections.filter
import kotlin.math.abs
import kotlin.math.max
// service/AutoTradingManager.kt // service/AutoTradingManager.kt
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
@@ -86,18 +82,20 @@ object AutoTradingManager {
fun startBackgroundScheduler() { fun startBackgroundScheduler() {
scope.launch { scope.launch {
while (isActive) { while (isActive) {
val seoulZone = ZoneId.of("Asia/Seoul")
val now = LocalTime.now(ZoneId.of("Asia/Seoul")) val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
val nowDate = LocalDate.now(seoulZone)
var checkTime = 60_000 * 3L var checkTime = 60_000 * 3L
val isTradingDay = MarketUtil.canTradeToday() val isTradingDay = nowDate.dayOfWeek.value in 1..5
if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) { if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) {
shouldShowFullWindow = true shouldShowFullWindow = true
// SystemSleepPreventer.wakeDisplay() SystemSleepPreventer.wakeDisplay()
} else if ((now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0)))) { } else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) {
// SystemSleepPreventer.sleepDisplay() SystemSleepPreventer.sleepDisplay()
}
if (!isTradingDay) {
checkTime = 60_000 * 30L
} }
// if (!isTradingDay) {
// checkTime = 60_000 * 30L
// }
delay(checkTime) // 1분마다 체크 delay(checkTime) // 1분마다 체크
} }
} }
@@ -109,47 +107,23 @@ object AutoTradingManager {
if (KisSession.isAvailBuyTime(now) && isSuccess && completeTradingDecision != null) { if (KisSession.isAvailBuyTime(now) && isSuccess && completeTradingDecision != null) {
val decision = completeTradingDecision val decision = completeTradingDecision
println("${decision.stockName} ${decision.decision}")
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
if (decision.decision == "BUY") { if (decision.decision == "BUY") {
var maxRealisticProfitRate = 0.0 val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
// AI가 이미 검증한 등급을 사용 (재계산 불필요) // AI가 이미 검증한 등급을 사용 (재계산 불필요)
val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE val grade = decision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
decision.analyzer?.let { a ->
val volatility = a?.calculateVolatilityForecast(a.daily, 20)
volatility?.let {
maxRealisticProfitRate = ((volatility.realisticHigh - decision.currentPrice) / decision.currentPrice) * 100.0
}
}
// 1. 통계적으로 도달 가능한 현실적인 최대 수익률 계산 (1표준편차 상단 기준)
// 2. 시스템 기본 설정 수익률과 비교
val baseProfitRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(grade.profitGuide)
// 3. 스마트 익절률 결정: 시스템 설정값이 통계적 한계를 넘어서면, 통계적 한계치로 눈높이를 낮춤
val finalProfitRate = if (maxRealisticProfitRate > 0.0 && baseProfitRate > maxRealisticProfitRate) {
max(maxRealisticProfitRate ,0.05)
} else {
baseProfitRate // 변동성이 충분히 크다면 원래 시스템 설정대로 진행
}
// 2. 최종 매수 실행 // 2. 최종 매수 실행
val gradeRate = KisSession.config.getValues(grade.allocationRate) val gradeRate = KisSession.config.getValues(grade.allocationRate)
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
decision.maxRealisticProfitRate = maxRealisticProfitRate val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
TradingLogStore.addLog(decision,"BUY",decision.summary(KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))) TradingLogStore.addLog(decision,"BUY",decision.summary())
var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings()?.any { it.code.equals(decision.stockCode) && it.quantity.toInt() > 2 && it.availOrderCount.toInt() > 0} ?: false
if (hasCodes == true) {
TradingLogStore.addNotice(decision.stockName,decision.stockCode,"물타기 시도 1주 매수")
}
val calculatedQty = if(hasCodes == true) KisSession.tradeConfig.lowerAverageStockCount else (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
excuteTrade( excuteTrade(
decision = decision, decision = decision,
orderQty = calculatedQty.toString(), orderQty = calculatedQty.toString(),
profitRate1 = finalProfitRate, profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
investmentGrade = grade, investmentGrade = grade
hasCode = hasCodes == true
) )
} else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석 } else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석
addToReanalysis(RankingStock(decision.stockCode, decision.stockName)) addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
@@ -228,7 +202,7 @@ object AutoTradingManager {
} }
} }
fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK, hasCode: Boolean) { fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
scope.launch { scope.launch {
var basePrice = decision.currentPrice var basePrice = decision.currentPrice
val tickSize = MarketUtil.getTickSize(basePrice) val tickSize = MarketUtil.getTickSize(basePrice)
@@ -239,95 +213,62 @@ object AutoTradingManager {
val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt() val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt()
if (!canAddNewPosition(maxStocks)) { if (!canAddNewPosition(maxStocks)) {
println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.")
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단") TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
TradingLogStore.addWatchLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") TradingLogStore.addWatchLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
} else if (KisSession.isAvailBuyTime(LocalTime.now())){ } else if (KisSession.isAvailBuyTime(LocalTime.now())){
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice hasStocks : ${stockCode.contains(stockCode)}" ) println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
var realOrderQty = orderQty
KisTradeService.postOrder(stockCode, realOrderQty, finalPrice.toLong().toString(), isBuy = true) KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo -> .onSuccess { realOrderNo ->
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
TradingLogStore.addLog( TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
decision,
"BUY",
"[${investmentGrade.displayName}] 주문 성공: $realOrderNo"
)
val sRate = -1.5 val sRate = -1.5
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
val effectiveProfitRate = val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
(profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
try {
var oldTarget = currentBalance?.getHoldings()?.first { it.availOrderCount.toInt() > 0 && it.code.equals(decision.stockCode) }
if (KisSession.tradeConfig.lowerAveragePrice && hasCode && oldTarget != null) {
var avgPrive = oldTarget.avgPrice.toDouble()
var qty = oldTarget.quantity.toDouble()
basePrice = avgPrive * 1.5//((avgPrive * qty) + (decision.currentPrice * orderQty.toInt())).div(qty!!.toInt() + (orderQty.toInt()))
println("물타기 ${avgPrive}, ${qty} ${basePrice}")
}
} catch (e:Exception) {e.printStackTrace()}
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedTarget =
MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0 val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
if (!hasCode) {
DatabaseFactory.saveAutoTrade( DatabaseFactory.saveAutoTrade(AutoTradeItem(
AutoTradeItem( orderNo = realOrderNo,
orderNo = realOrderNo, code = stockCode,
code = stockCode, name = stockName,
name = stockName, quantity = inputQty,
quantity = inputQty, profitRate = effectiveProfitRate,
profitRate = effectiveProfitRate, stopLossRate = sRate,
stopLossRate = sRate, targetPrice = calculatedTarget,
targetPrice = calculatedTarget, stopLossPrice = calculatedStop,
stopLossPrice = calculatedStop, status = "PENDING_BUY",
status = "PENDING_BUY", isDomestic = true
isDomestic = true ))
)
) TradingReportManager.recordTradeDecision(
TradingReportManager.recordTradeDecision( orderNo = realOrderNo,
orderNo = realOrderNo, stockCode = stockCode,
stockCode = stockCode, stockName = stockName,
stockName = stockName, isBuy = true,
isBuy = true, orderQty = inputQty,
orderQty = inputQty, reason = decision.reason ?: "", // AI 이유
reason = decision.reason ?: "", // AI 이유 decision = decision // AI 객체 통째로 전달
decision = decision // AI 객체 통째로 전달
)
syncAndExecute(realOrderNo)
}
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
TradingLogStore.addLog(
decision,
"BUY",
"[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${
String.format(
"%.4f",
effectiveProfitRate
)
}%): $realOrderNo"
) )
syncAndExecute(realOrderNo)
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
} }
.onFailure { .onFailure {
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice") println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
if (it.message?.contains("주문가능금액을 초과") == true) { if (it.message?.contains("주문가능금액을 초과") == true) {
AutoTradingManager.addToReanalysis( AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
RankingStock( TradingLogStore.addWatchLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가")
mksc_shrn_iscd = stockCode,
hts_kor_isnm = stockName
)
)
TradingLogStore.addWatchLog(
decision,
"WATCH",
"${it.message ?: " 매수 실패"} => 재분석 대기열에 추가"
)
} else { } else {
TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패") TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
} }
} }
} else { } else {
@@ -347,7 +288,6 @@ object AutoTradingManager {
var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy -> var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy ->
scope.launch { scope.launch {
val exec = ExecutionData(orderNo, code, price, qty, isBuy) val exec = ExecutionData(orderNo, code, price, qty, isBuy)
println("exec >> ${exec}")
executionCache[orderNo] = exec executionCache[orderNo] = exec
syncAndExecute(orderNo) syncAndExecute(orderNo)
} }
@@ -358,7 +298,6 @@ object AutoTradingManager {
if (processingIds.contains(orderNo)) return if (processingIds.contains(orderNo)) return
processingIds.add(orderNo) processingIds.add(orderNo)
try { try {
val dbItem = DatabaseFactory.findByOrderNo(orderNo) val dbItem = DatabaseFactory.findByOrderNo(orderNo)
val execData = executionCache[orderNo] val execData = executionCache[orderNo]
@@ -366,15 +305,12 @@ object AutoTradingManager {
if (dbItem != null && execData != null && execData.isFilled) { if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) { if (dbItem.status == TradeStatus.PENDING_BUY) {
// ✅ 1. 진짜 사온 가격 (실제 매수 체결가) // ✅ 1. 진짜 사온 가격 (실제 매수 체결가)
var actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다. // 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다. // 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity) TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity)
var hasCodes = KisSession.tradeConfig.lowerAveragePrice && currentBalance?.getHoldings()?.any { it.code.equals(dbItem.code) && it.quantity.toInt() > 2 && dbItem.quantity == KisSession.tradeConfig.lowerAverageStockCount } ?: false
if (hasCodes) {
actualBuyPrice = actualBuyPrice * 1.1
}
val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05 val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05
val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
@@ -405,12 +341,13 @@ object AutoTradingManager {
// ✅ 2. 매도 완료 시점 (실제 매도 체결가) // ✅ 2. 매도 완료 시점 (실제 매도 체결가)
val actualSellPrice = execData.price.toDoubleOrNull() ?: 0.0 val actualSellPrice = execData.price.toDoubleOrNull() ?: 0.0
val actualSellQty = execData.qty.toIntOrNull() ?: dbItem.quantity val actualSellQty = execData.qty.toIntOrNull() ?: dbItem.quantity
// 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록 // 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록
TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty) TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty)
println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}") println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}")
TradingLogStore.addSellLog(dbItem.name,actualSellPrice.toString(),"SELL","매도 완료")
myOredsAndBalanceCodes.remove(dbItem.code)
TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림 TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
@@ -425,17 +362,12 @@ object AutoTradingManager {
/** /**
* 자동 발굴 루프 시작 Watchdog 실행 * 자동 발굴 루프 시작 Watchdog 실행
*/ */
fun startAutoDiscoveryLoop(doStart : Boolean = false) { fun startAutoDiscoveryLoop() {
if (isRunning()) return if (isRunning()) return
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작 // 1. 기존 Watchdog이 있다면 제거 후 새로 시작
watchdogJob?.cancel() watchdogJob?.cancel()
watchdogJob = scope.launch { watchdogJob = scope.launch {
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
if (doStart && activeTrades.isNotEmpty() && !KisSession.isAvailBuyTime(now)) {
executeClosingLiquidation(activeTrades)
}
while (isActive) { while (isActive) {
delay(WATCHDOG_CHECK_INTERVAL) delay(WATCHDOG_CHECK_INTERVAL)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -460,9 +392,7 @@ object AutoTradingManager {
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})" "거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
) )
} else { } else {
val now = LocalTime.now() val targetProfitLimit = if (holding.isTodayEntry) {
val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(16, 0))) {
// 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도) // 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도)
KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX) KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX)
} else { } else {
@@ -523,11 +453,32 @@ object AutoTradingManager {
&& holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) { && holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) {
println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}") println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}")
val profit = holding.profitRate.toDouble() val profit = holding.profitRate.toDouble()
TradingLogStore.addNotice( // TradingLogStore.addNotice(
"보유주식[${holding.name}]", // "보유주식[${holding.name}]",
holding.code, // holding.code,
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함." // "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함."
) // )
var targetPrice = holding.avgPrice.toDouble()
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
orderDivision = if (marketCode.equals("Y")) "07" else "",
marketCode = if (marketCode.equals("Y")) "KRX" else "NXT"
).onSuccess { newOrderNo ->
println("✅ [${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 손절가이드에 따라 매매 주문 완료] ${holding.name}: $newOrderNo")
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"☠️ 보유 주식 손절 처리 [수익률 : ${profit}%] ${holding.valuationProfitAmount} 손해 중이며 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"}에 손절가이드에 따라 매매 주문 완료."
)
}.onFailure { err->
println("✅ [${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 손절가이드에 따라 매매 주문 실패] ${holding.name}: $err")
}
} }
analyzeDeepLossHoldingsAfterMarket(holding) analyzeDeepLossHoldingsAfterMarket(holding)
} }
@@ -563,7 +514,9 @@ object AutoTradingManager {
targetPrice = targetPrice targetPrice = targetPrice
isBefore930 = true isBefore930 = true
} else { } else {
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice)) targetPrice = MarketUtil.roundToTickSize(
targetPrice + MarketUtil.getTickSize(targetPrice)
)
} }
println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도") println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
tradeService.postOrder( tradeService.postOrder(
@@ -579,18 +532,20 @@ object AutoTradingManager {
"SELL", "SELL",
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료" "🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
) )
DatabaseFactory.saveAutoTrade(AutoTradeItem( DatabaseFactory.saveAutoTrade(
orderNo = newOrderNo, AutoTradeItem(
code = holding.code, orderNo = newOrderNo,
name = holding.name, code = holding.code,
quantity = holding.quantity.toInt(), name = holding.name,
profitRate = 0.0, quantity = holding.quantity.toInt(),
stopLossRate = 0.0, profitRate = 0.0,
targetPrice = targetPrice.toDouble(), stopLossRate = 0.0,
stopLossPrice = 0.0, targetPrice = targetPrice.toDouble(),
status = "SELLING", stopLossPrice = 0.0,
isDomestic = true status = "SELLING",
)) isDomestic = true
)
)
syncAndExecute(newOrderNo) syncAndExecute(newOrderNo)
}.onFailure { }.onFailure {
TradingLogStore.addSellLog( TradingLogStore.addSellLog(
@@ -601,35 +556,7 @@ object AutoTradingManager {
) )
} }
} else { } else {
var errMsg = "" if (KisSession.config.stop_Loss
var isSuccess = false
if (KisSession.tradeConfig.autoSellOrder
&& holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.tradeConfig.autoSellOrderMin
&& holding.profitRate.toDouble() >= KisSession.tradeConfig.autoSellOrderMax
&& holding.avgPrice.toDouble() > holding.currentPrice.toDouble()) {
var targetPrice = holding.avgPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend)
tradeService.postOrder(
stockCode = holding.code,
qty = holding.availOrderCount,
price = targetPrice.toInt().toString(),
isBuy = false,
).onSuccess { newOrderNo ->
println("✅ [보유 주식 손절 처리] ${holding.name} 매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문")
isSuccess = true
}.onFailure { err->
println("✅ [보유 주식 손절 처리] ${holding.name} 실패 ${targetPrice} ${err.message}")
errMsg = err.message.toString()
}
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"매수가 기준 (${holding.avgPrice.toDouble()} 3호가 위[${targetPrice}] 매도 주문 ${if (isSuccess) "성공" else "실패[${errMsg}]"}"
)
} else if (KisSession.config.stop_Loss
&& holding != null && holding.quantity.toInt() > 0 && holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0 && holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE) && holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)
@@ -637,9 +564,9 @@ object AutoTradingManager {
&& holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) { && holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) {
println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}") println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}")
val profit = holding.profitRate.toDouble() val profit = holding.profitRate.toDouble()
var targetPrice = holding.currentPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * KisSession.tradeConfig.autoSellOrderAppend)
var targetPrice = if (KisSession.tradeConfig.autoSellOrder ) holding.avgPrice.toDouble() else holding.currentPrice.toDouble()
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice) * 3.0)
tradeService.postOrder( tradeService.postOrder(
stockCode = holding.code, stockCode = holding.code,
@@ -648,15 +575,21 @@ object AutoTradingManager {
isBuy = false, isBuy = false,
).onSuccess { newOrderNo -> ).onSuccess { newOrderNo ->
println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.") println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.")
TradingLogStore.addSellLog(
holding.code,
targetPrice.toString(),
"SELL",
"☠️ 보유 주식 손절 처리 [수익률 : ${profit}%] ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
)
}.onFailure { err-> }.onFailure { err->
println("✅ [보유 주식 손절 처리] 실패 ${err.message}") println("✅ [보유 주식 손절 처리] 실패 ${err.message}")
} }
TradingLogStore.addNotice( // TradingLogStore.addNotice(
"보유주식[${holding.name}]", // "보유주식[${holding.name}]",
holding.code, // holding.code,
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도." // "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
) // )
} }
analyzeDeepLossHoldingsAfterMarket(holding , true) analyzeDeepLossHoldingsAfterMarket(holding , true)
} }
@@ -739,7 +672,7 @@ object AutoTradingManager {
// 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환 // 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환
return true //(currentBalance?.getHoldings()?.count { it.availOrderCount.toInt() > 0 } ?: 0) > maxAllowedStocks return true//myOredsAndBalanceCodes.size < maxAllowedStocks
} }
suspend fun tryRefreshToken() { suspend fun tryRefreshToken() {
@@ -809,9 +742,8 @@ object AutoTradingManager {
} }
withTimeout(CYCLE_TIMEOUT) { withTimeout(CYCLE_TIMEOUT) {
println("⏱️ [Cycle Start] ${LocalTime.now()}") println("⏱️ [Cycle Start] ${LocalTime.now()}")
val activeTrades = DatabaseFactory.findAllMonitoringTrades() if (now.isAfter(KisSession.endTime())) {
if (now.isAfter(KisSession.endBuyTime()) && activeTrades.isNotEmpty()) { executeClosingLiquidation(KisTradeService)
executeClosingLiquidation(activeTrades)
} else { } else {
executeMarketLoop() executeMarketLoop()
} }
@@ -844,8 +776,9 @@ object AutoTradingManager {
isSystemReadyToday = false isSystemReadyToday = false
shouldShowFullWindow = false shouldShowFullWindow = false
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
} else if (now.isAfter(KisSession.startTime().minusMinutes(20)) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow) { } else if (now.isAfter(KisSession.startTime().minusMinutes(10)) && now.isBefore(KisSession.startTime()) && !isSystemReadyToday) {
if (MarketUtil.canTradeToday()) { if (MarketUtil.canTradeToday()) {
SystemSleepPreventer.wakeDisplay()
shouldShowFullWindow = true shouldShowFullWindow = true
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.") println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true) tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true)
@@ -856,31 +789,28 @@ object AutoTradingManager {
} }
} }
var loadedTops = mutableListOf<Pair<String, String>>() var loadedTops = mutableListOf<Pair<String, String>>()
var defaultStockCount = 30
fun poll100Stocks(): List<Pair<String, String>> {
val count = minOf(loadedTops.size, 150)
if (count == 0) return emptyList()
// 앞의 100개를 복사
val batch = loadedTops.subList(0, count).toList()
// 원본에서 삭제 (이 작업이 큐의 pop/remove 역할을 합니다)
loadedTops.subList(0, count).clear()
return batch
}
var currentBalance : UnifiedBalance? = null var currentBalance : UnifiedBalance? = null
private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond) var myOredsAndBalanceCodes : MutableSet<String> = mutableSetOf()
private val FETCH_INTERVAL = 2 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms) suspend fun checkBalance(isMorning: Boolean = true) {
if (isMorning) {
suspend fun checkBalance() { currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
val currentTime = System.currentTimeMillis() if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
if (currentBalance == null || (currentTime - lastFetchTime) > FETCH_INTERVAL) { if (KisSession.tradeConfig.auto_cancel_pending_buy) { checkAndCancelPendingBuyOrders() }
KisTradeService.fetchIntegratedBalance().getOrNull()?.let {
currentBalance = it
lastFetchTime = currentTime // 호출 성공 시 현재 시간으로 갱신
println("잔고 동기화 완료")
} ?: run {
println("잔고 조회 실패 (네트워크 오류 등)")
}
} else { } else {
// 30분이 지나지 않았다면 기존에 저장된 currentBalance를 그대로 사용
println("${(FETCH_INTERVAL / (1000 * 60))}분이 지나지 않아 기존 잔고 데이터 유지 (남은 시간: ${(FETCH_INTERVAL - (currentTime - lastFetchTime)) / 1000}초)")
} }
if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
if (KisSession.tradeConfig.auto_cancel_pending_buy) { checkAndCancelPendingBuyOrders() }
} }
suspend fun checkAndCancelPendingBuyOrders( suspend fun checkAndCancelPendingBuyOrders(
@@ -945,79 +875,48 @@ object AutoTradingManager {
suspend fun executeMarketLoop() { suspend fun executeMarketLoop() {
myOredsAndBalanceCodes.clear()
checkBalance() checkBalance()
var myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong() val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
myCash = max(myCash,KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong()) val myHoldings = currentBalance?.getHoldings()?.map {
val myHoldings = currentBalance?.getHoldings()?.filter { !it.isTodayEntry }?.map { it.code }?.toSet() ?: emptySet() myOredsAndBalanceCodes.add(it.code)
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code } it.code }?.toSet() ?: emptySet()
var now = LocalTime.now(ZoneId.of("Asia/Seoul")) val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
myOredsAndBalanceCodes.add(it.code)
it.code
}
if (remainingCandidates.isEmpty()) { if (remainingCandidates.isEmpty()) {
if (loadedTops.size < defaultStockCount) { if (loadedTops.size < 100) {
loadedTops.addAll(StockUniverseLoader.loadUniverse()) loadedTops.addAll(StockUniverseLoader.loadUniverse())
loadedTops.shuffle()
println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.") println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.")
} }
loadedTops.shuffle() poll100Stocks().forEach { (code, name) ->
val count = minOf(loadedTops.size, defaultStockCount) addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name))
for (i in 0 ..< count) {
loadedTops.removeFirst().let {
addToReanalysis(RankingStock(mksc_shrn_iscd = it.first, hts_kor_isnm = it.second))
}
} }
val candidates: MutableList<RankingStock> = fetchCandidates(KisTradeService).apply { val candidates: MutableList<RankingStock> = fetchCandidates(KisTradeService).apply {
}.filter { }.filter {
val rate = it.prdy_ctrt.toDouble() val rate = it.prdy_ctrt.toDouble()
val corpInfo = DartCodeManager.getCorpCode(it.code) val corpInfo = DartCodeManager.getCorpCode(it.code)
val isOk = (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs(KisSession.tradeConfig.minusFilter) * -1)) val isOk = (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (KisSession.tradeConfig.minusFilter * -1))
if (corpInfo?.cName.isNullOrEmpty()) { if (corpInfo?.cName.isNullOrEmpty()) {
false false
} else if (it.code !in myHoldings &&
it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } &&
it.code !in failList &&
it.code !in isSafetyBeltStockCodes){
isOk
} else { } else {
false isOk
} }
} }
.filter { !it.name.contains("호스팩", true) } .filter { !it.name.contains("호스팩", true) }
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
.toMutableList() .toMutableList()
if (reanalysisList.isNotEmpty()) { if (reanalysisList.isNotEmpty()) {
candidates.addAll(reanalysisList) candidates.addAll(reanalysisList)
} }
reanalysisList.clear() reanalysisList.clear()
if (KisSession.tradeConfig.lowerAveragePrice) { remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList}
currentBalance?.getHoldings()?.map { .distinctBy { it.code })
if(
it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount &&
it.profitRate.toDouble() < 0.0 &&
it.profitRate.toDouble() < (abs(KisSession.tradeConfig.lowerAverageMaxRate) * -1) &&
it.profitRate.toDouble() > (abs(KisSession.tradeConfig.lowerAverageMinRate) * -1))
{
candidates.add(RankingStock(mksc_shrn_iscd = it.code, hts_kor_isnm = it.name))
println("물타기 대상 추가 ${it.name}[${it.code}]")
var oldTarget = it
if ( oldTarget != null) {
var avgPrive = oldTarget.avgPrice.toDouble()
var qty = oldTarget.quantity.toDouble()
var basePrice = ((avgPrive * qty) + it.currentPrice.toDouble()).div(qty!!.toInt() + 1)
println("물타기 ${avgPrive}, ${qty} ${basePrice}")
}
}
}
}
remainingCandidates.addAll(candidates.filter {
(if (KisSession.tradeConfig.lowerAveragePrice) { true } else {it.code !in myHoldings}) &&
it.code !in pendingStocks &&
it.code !in executionCache.values.map { it.code } &&
it.code !in failList &&
it.code !in isSafetyBeltStockCodes
}.distinctBy { it.code })
remainingCandidates.shuffle()
} else { } else {
println("미확인 데이터 ${remainingCandidates.size}") println("미확인 데이터 ${remainingCandidates.size}")
} }
@@ -1040,7 +939,7 @@ object AutoTradingManager {
iterator.remove() iterator.remove()
} }
println("남은 후보군 개수 : ${totalCount}") println("남은 후보군 개수 : ${totalCount}")
delay(500) delay(100)
} }
} }
sellSchedule() sellSchedule()
@@ -1061,7 +960,7 @@ object AutoTradingManager {
if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) { if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) {
cancelAllPendingSellOrders() cancelAllPendingSellOrders()
isExecuted = true isExecuted = true
} else if ( (now.isBefore(LocalTime.of(15,40)) && now.isAfter(KisSession.endBuyTime())) ) { } else if ( (now.isBefore(LocalTime.of(16,0)) && now.isAfter(KisSession.endBuyTime())) ) {
val unfilledResult = KisTradeService.fetchUnfilledOrders() val unfilledResult = KisTradeService.fetchUnfilledOrders()
unfilledResult.onSuccess { response -> unfilledResult.onSuccess { response ->
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
@@ -1073,7 +972,7 @@ object AutoTradingManager {
} }
} }
isExecuted = true isExecuted = true
} else if (now.hour == 9 && now.minute % KisSession.tradeConfig.excuteMinCheck == 0) { } else if (now.hour == 9) {
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
" - ", " - ",
" - ", " - ",
@@ -1083,11 +982,7 @@ object AutoTradingManager {
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.") println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
checkBalance() checkBalance()
isExecuted = true isExecuted = true
} else if ( } else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 1)) {
(
(now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) ||
(now.isAfter(LocalTime.of(15,40)) && now.isBefore(LocalTime.of(20,0)) && KisSession.tradeConfig.after_nxt)
) && (currentMinute % 2 == 0)) {
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
" - ", " - ",
" - ", " - ",
@@ -1106,10 +1001,7 @@ object AutoTradingManager {
isExecuted = true isExecuted = true
} }
if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 } if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 }
if (now.hour >= 20) { if (now.hour >= 20) { executionCountMap.clear() }
executionCountMap.clear()
noticeFilter.clear()
}
} }
@@ -1134,148 +1026,48 @@ object AutoTradingManager {
print("-> 기업명을 못찾아서 제외 | ") print("-> 기업명을 못찾아서 제외 | ")
return@withTimeout return@withTimeout
} }
if(currentBalance?.getHoldings()?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2} == true) {
println("물타기 대상 분석")
}
callback(TradingDecision().apply { callback(TradingDecision().apply {
this.stockCode = stock.code this.stockCode = stock.code
this.confidence = -1.0 this.confidence = -1.0
this.stockName = stock.name this.stockName = stock.name
}, false) }, false)
val dailyData = val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
val today = dailyData.lastOrNull() ?: null val today = dailyData.lastOrNull() ?: null
var rate = today?.getFluctuationRate() ?: 0.0 if (today == null) {
val isOk = ((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1))) failList.add(stock.code)
delay(50) print("-> 금일 금액 조회 실패 | ")
// 1. var 대신 val을 사용해야 아래에서 스마트 캐스트가 작동하여 !!를 안 써도 됩니다.
val currentStock = KisTradeService.fetchCurrentPrice(stock.code).getOrNull()
if (today == null || currentStock == null) {
// failList.add(stock.code)
print("-> 금일 금액 조회 실패 | ${isOk}")
return@withTimeout return@withTimeout
} }
// 3. 위에서 확실하게 null 체크를 했으므로, 이제 currentStock은 절대 null이 아닙니다. val currentPrice = today.stck_prpr.toDouble()
// 안전하게(Safe call ? 없이) 바로 접근 가능합니다.
val currentPrice = currentStock.stck_prpr.toDouble()
println("${stock.name}[${stock.code}] 현재가 : ${currentPrice} , 변동률 : ${rate} , 거래 기준 : ${isOk}")
// 4. 위험한 !! 단언 기호 없이 깔끔하게 호출 if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
val riskResult = RiskManager.evaluateRisk(currentStock) print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
if (!riskResult.isSafe) {
print("-> ${stock.name}[${stock.code}] 검문소 탈락: ${riskResult.rejectReason}")
return@withTimeout return@withTimeout
} }
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
if (!isOk || (myCash > 10L && currentPrice > myCash) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> [${stock.name}] 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
return@withTimeout
}
val analyzer = coroutineScope {
// 🌟 [추가] 고도화된 사전 필터링 (검문소) val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData } val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) }
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?) TechnicalAnalyzer().apply {
println("(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.8).toInt()}") this.daily = dailyData
val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, (dailyData.size * 0.8).toInt()) this.min30 = min30.await()
val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0 this.weekly = weekly.await()
this.monthly = monthly.await()
// 2. 일봉 기준 반등 주기 통계 추출 (일주일 내 승부 가능한가?)
val dailyStats = tempAnalyzer.calculateDynamicReboundStats(dailyData, 5.0)
val isApproaching = tempAnalyzer.checkReboundApproaching(
candles = dailyData,
avgReboundTerm = dailyStats.avgReboundPeriod,
dropThreshold = dailyStats.avgDropRate,
timeTolerance = dailyStats.timeTolerance
)
print("-> [${stock.name}] 필터링 ${dailyStats.avgReboundPeriod} ${dailyStats.avgDropRate} ${dailyStats.timeTolerance}")
val isSteadyUptrend = false //tempAnalyzer.checkSteadyUptrend(dailyData)
// 🌟 [수정] 조건 통합 (OR 조건)
val isProfitable = expectedProfitRate >= KisSession.tradeConfig.minExpectedProfitRate || dailyStats.avgReboundAmplitude >= KisSession.tradeConfig.minExpectedProfitRate
// 반등 주기에 도달했거나(Mean Reversion), 안정적으로 뻗어나가는 우상향 종목(Trend Following)이면 통과
val isValidEntryTiming = (dailyStats.isValid && isApproaching && dailyStats.avgReboundPeriod <= KisSession.tradeConfig.maxExpectedReboundDays && dailyStats.avgReboundPeriod >= KisSession.tradeConfig.minExpectedReboundDays) || isSteadyUptrend
val currentAtr = tempAnalyzer.calculateATR(dailyData)
if (!isProfitable || !isValidEntryTiming) {
print("-> [${stock.name}] 조건 미달 필터링 (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming) | ")
return@withTimeout // 조건에 맞지 않으면 주봉/월봉 API 호출 및 LLM 분석 없이 즉시 다음 종목으로 넘어감
}
val dropPrediction = tempAnalyzer.predictDropBottom(dailyData, dailyStats, volatility, currentAtr)
if (dropPrediction != null) {
// 💡 [방어 로직 1] 아직 바닥까지 한참 남았다면 지하실 방지
if (dropPrediction.remainingDropRate < -2.0 && !dropPrediction.isBottomZone) {
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 추가 하락 위험) | ")
return@withTimeout
}
// 💡 [방어 로직 2 - 신규] 가격은 바닥권에 왔지만, 캔들에 브레이크(지지)가 걸렸는가?
if (dropPrediction.isBottomZone) {
val hasBrake = tempAnalyzer.checkBrakeAndReversal(dailyData)
if (!hasBrake) {
print("-> [${stock.name}] 바닥 가격 도달했으나, 브레이크(지지/거래량 진정) 미확인. 떨어지는 칼날 회피 | ")
return@withTimeout // 브레이크가 없으면 매수 안 함!
}
} }
} }
if (analyzer.isValid()) {
if (KisSession.tradeConfig.isUpcomingDividend) { RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess ->
var dividend = KisTradeService.fetchUpcomingDividend(stock.code).getOrNull() callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
if(dividend?.hasDividend == true){
println("[${stock.name}] 배당락일 ${dividend.exDividendDate} : ${dividend.dividendAmount}")
} else {
println("[${stock.name}] 배당 정보 없어서 분석 종료")
return@withTimeout
} }
} else {
println("[${stock.name}] 배당 정보 무관 함.")
}
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()}) (예측수익: ${"%.1f".format(expectedProfitRate)}%, 주기: ${"%.1f".format(dailyStats.avgReboundPeriod)}일, 진입권: $isValidEntryTiming)")
if (!isSafetyBeltStockCodes.contains(stock.code)) {
val analyzer = coroutineScope {
val min30 = async { tradeService.fetchChartData(stock.code, true).getOrDefault(emptyList()) }
delay(20)
val weekly =
async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
delay(20)
val monthly =
async { tradeService.fetchPeriodChartData(stock.code, "M", true).getOrDefault(emptyList()) }
delay(20)
TechnicalAnalyzer().apply {
this.daily = dailyData
delay(50)
this.min30 = min30.await()
delay(50)
this.weekly = weekly.await()
delay(50)
this.monthly = monthly.await()
}
}
if (analyzer.isValid()) {
println("✅ [분석 시작] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
RagService.processStock(currentPrice, analyzer, stock.name, stock.code) { decision, isSuccess ->
callback(decision?.apply { this.currentPrice = currentPrice }, isSuccess)
}
} else {
println("✅ [분석 실패] ${stock.name} (${LocalTime.now()} 분석 데이터 정합성 -> ${analyzer.isValid()})")
}
} else {
println("재무 안정성 부족 (캐시)")
} }
println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})") println("✅ [분석 종료] ${stock.name} (${LocalTime.now()})")
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
println("❌ [Stock Error] ${stock.name}: ${e.message}") println("❌ [Stock Error] ${stock.name}: ${e.message}")
} }
} }
@@ -1318,15 +1110,23 @@ object AutoTradingManager {
} }
private suspend fun executeClosingLiquidation(activeTrades: List<AutoTradeItem>) { private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
val realHoldings = balanceResult?.getHoldings()?.associateBy { it.code } ?: emptyMap()
activeTrades.forEach { trade -> activeTrades.forEach { trade ->
try { try {
DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED) if (!realHoldings.containsKey(trade.code)) {
DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED)
return@forEach
}
// 마감 정리 로직 (필요 시 주석 해제하여 사용)
println("📢 [마감 정리 체크] ${trade.name}") println("📢 [마감 정리 체크] ${trade.name}")
} catch (e: Exception) { } catch (e: Exception) {
println("⚠️ [마감 에러] ${trade.name}: ${e.message}") println("⚠️ [마감 에러] ${trade.name}: ${e.message}")
} }
delay(5) delay(200)
} }
} }
+53 -240
View File
@@ -50,10 +50,13 @@ 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 kotlin.math.abs import java.time.LocalTime
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@@ -105,14 +108,34 @@ 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)) {
Button( Row(modifier = Modifier.fillMaxWidth(),
onClick = { horizontalArrangement = Arrangement.spacedBy(4.dp),
coroutineScope.launch { ) {
// index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0)) Button(
listState.animateScrollToItem(filteredLogs.size - 1) onClick = {
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),
@@ -224,9 +247,9 @@ fun TradingDecisionLog() {
Text( Text(
text = log.decision, text = log.decision,
color = when (log.decision) { color = when (log.decision) {
"BUY" -> Color.Red "BUY" -> Color(0xFF800080)
"SETTING" -> Color(0xFFFFA500) "SETTING" -> Color(0xFFFFA500)
"SELL" -> Color(0xFF800080) "SELL" -> if (log.reason.contains("손절 처리")) Color.Blue else Color.Red
"HOLD" -> Color.DarkGray "HOLD" -> Color.DarkGray
"ANALYZER" -> Color.Green "ANALYZER" -> Color.Green
"PASS" -> Color.Yellow "PASS" -> Color.Yellow
@@ -510,28 +533,7 @@ 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 = "미체결 자동 취소 (매수)",
@@ -564,37 +566,23 @@ 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.isUpcomingDividend, initialChecked = tradeConfig.before_nxt,
onCheckedChange = { tradeConfig.isUpcomingDividend = it onCheckedChange = { tradeConfig.before_nxt = 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() }
) )
@@ -607,127 +595,6 @@ 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 = "너무 작으면 초단타,널뛰기, 이 보다 커야 분석함"
)
}
} }
} }
@@ -873,61 +740,9 @@ 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 = "",
@@ -937,7 +752,7 @@ fun SettingInputField(
// 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책) // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
var localText by remember { mutableStateOf(initialValue) } var localText by remember { mutableStateOf(initialValue) }
Column(modifier = modifier ?: Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
value = localText, value = localText,
onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영 onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
@@ -976,12 +791,8 @@ 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 = "",
@@ -991,7 +802,9 @@ 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(),
+20 -24
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,29 +16,25 @@ object MarketUtil {
val dayOfWeek = now.dayOfWeek.value val dayOfWeek = now.dayOfWeek.value
if (dayOfWeek >= 6) return false if (dayOfWeek >= 6) return false
// 1. 주말 체크 (토, 일) // 1. 주말 체크 (토, 일)
return true val cachedHoliday = DatabaseFactory.getHoliday(todayStr)
// try { if (cachedHoliday != null) {
// if (canTradeDays.contains(todayStr)) { println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(cachedHoliday) "휴장" else "영업일"}")
// println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}") return !cachedHoliday
// return canTradeDays.get(todayStr) == true }
// }
// } catch (e: Exception) {e.printStackTrace()} // 3. DB에 없으면 API 호출
// return try {
// val result = KisTradeService.fetchIsHoliday(todayStr)
// // 3. DB에 없으면 API 호출 val isHoliday = result.getOrDefault(true)
// return try {
// val result = KisTradeService.fetchIsHoliday(todayStr) // 결과를 DB에 저장하여 다음 실행 시 재사용
// val canTrade = result.getOrDefault(false) DatabaseFactory.saveHoliday(todayStr, isHoliday)
//
// // 결과를 DB에 저장하여 다음 실행 시 재사용 println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다.")
// canTradeDays.put(todayStr, canTrade) !isHoliday
// } catch (e: Exception) {
// println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다. ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}" ) false
// canTrade }
// } catch (e: Exception) {
// e.printStackTrace()
// false
// }
} }
fun isKoreanMarketOpen(): Boolean { fun isKoreanMarketOpen(): Boolean {
-16
View File
@@ -11378,21 +11378,5 @@
{ {
"code": "408470", "code": "408470",
"name": "한패스" "name": "한패스"
},
{
"code": "403810",
"name": "아이엘로보틱스"
},
{
"code": "059180",
"name": "엔더블유시"
},
{
"code": "950250",
"name": "테라뷰"
},
{
"code": "288180",
"name": "케이피항공산업"
} }
] ]