Compare commits
35
Commits
g
..
3fdb298caa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fdb298caa | ||
|
|
acd26abe1e | ||
|
|
b134dea1e1 | ||
|
|
7474558136 | ||
|
|
81ce9b1539 | ||
|
|
c55b089fcd | ||
|
|
fd8283c507 | ||
|
|
ce07537eef | ||
|
|
8e145803d8 | ||
|
|
27d330677e | ||
|
|
71fdabfc32 | ||
|
|
3fd9e3d833 | ||
|
|
affad2743e | ||
|
|
075a085b92 | ||
|
|
355db7fe20 | ||
|
|
4a72a30ab6 | ||
|
|
df5febfb42 | ||
|
|
aa4f8daadf | ||
|
|
d57f1698af | ||
|
|
91b616e127 | ||
|
|
21242d5ca4 | ||
|
|
d26eb34f1d | ||
|
|
83d671bece | ||
|
|
059d1830b7 | ||
|
|
ada0d9d6fe | ||
|
|
9d43c04670 | ||
|
|
d0bdc57a1e | ||
|
|
558a39e2d3 | ||
|
|
510e19b2e8 | ||
|
|
3fcce5e5c6 | ||
|
|
74c3e2462d | ||
|
|
ef32260bdb | ||
|
|
1cca11edc4 | ||
|
|
e94869b9e7 | ||
|
|
ce63b5760a |
@@ -313,7 +313,7 @@ fun main() = application {
|
||||
AutoTradingManager.isSystemCleanedUpToday = false
|
||||
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
AutoTradingManager.startAutoDiscoveryLoop()
|
||||
AutoTradingManager.startAutoDiscoveryLoop(true)
|
||||
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
|
||||
KisWebSocketManager.connect()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,83 @@ class TechnicalAnalyzer {
|
||||
|
||||
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) 흐름을 결합한 종합 신호 생성
|
||||
*/
|
||||
@@ -49,8 +126,18 @@ class TechnicalAnalyzer {
|
||||
|
||||
// 2. 점수 정교화 (가점/감점 요인)
|
||||
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
||||
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
|
||||
refinedScore += 10.0
|
||||
val trendConditions = listOf(
|
||||
calculateChange(monthly) > 0, // 장기 추세
|
||||
calculateChange(weekly) > 0, // 중기 추세
|
||||
calculateChange(daily.takeLast(5)) > 0 // 단기 추세
|
||||
)
|
||||
|
||||
val passedCount = trendConditions.count { it == true }
|
||||
|
||||
if (passedCount >= 2) {
|
||||
refinedScore += 10.0 // 2개 이상이 상승 추세면 가점 부여
|
||||
} else if (passedCount == 3) {
|
||||
refinedScore += 15.0 // 3개 모두 일치하면 '초강력 추세'로 보너스 추가 가점 (선택 사항)
|
||||
}
|
||||
|
||||
// [보완] 자금 유입 강도(MFI) 반영
|
||||
@@ -67,6 +154,14 @@ class TechnicalAnalyzer {
|
||||
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
|
||||
if (bodyRange > atr * 1.2) refinedScore += 7.0
|
||||
|
||||
// 🌟 [추가 보완 1] 기간별 최고가 저항선 감점 적용
|
||||
val highPricePenalty = calculateHighPricePenalty()
|
||||
refinedScore += highPricePenalty // 음수 값이 반환되므로 가산
|
||||
|
||||
// 🌟 [추가 보완 2] 낙폭 과대 후 단기 반등 추세 가점 적용
|
||||
val reboundBonus = calculateReboundBonus()
|
||||
refinedScore += reboundBonus
|
||||
|
||||
// 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석
|
||||
if (min30.size >= 15) {
|
||||
val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들)
|
||||
@@ -99,6 +194,65 @@ 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를 막아주는 유연한 과열 판별 로직
|
||||
*/
|
||||
@@ -157,6 +311,51 @@ class TechnicalAnalyzer {
|
||||
}
|
||||
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 {
|
||||
if (candles.size < period + 1) return 50.0
|
||||
@@ -241,4 +440,251 @@ $standardizedScores
|
||||
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
|
||||
""".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)을 계산합니다.
|
||||
*/
|
||||
fun predictDropBottom(
|
||||
candles: List<CandleData>,
|
||||
reboundStats: ReboundStats,
|
||||
volatility: VolatilityForecast
|
||||
): DropPrediction? {
|
||||
// 데이터가 부족하거나, 유의미한 과거 반등 패턴이 없으면 예측 불가
|
||||
if (candles.size < 20 || !reboundStats.isValid) return null
|
||||
|
||||
// 1. 최근 20일 내 단기 고점 파악 (현재 진행 중인 하락 파동의 시작점)
|
||||
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
|
||||
var recentPeakPrice = 0.0
|
||||
|
||||
for (i in recentCandles.indices.reversed()) {
|
||||
val highPrice = recentCandles[i].stck_hgpr.toDouble()
|
||||
if (highPrice > recentPeakPrice) {
|
||||
recentPeakPrice = highPrice
|
||||
}
|
||||
}
|
||||
|
||||
if (recentPeakPrice == 0.0) return null
|
||||
|
||||
val currentPrice = candles.last().stck_prpr.toDouble()
|
||||
|
||||
// 2. 현재까지의 하락률 계산
|
||||
val currentDropRate = (recentPeakPrice - currentPrice) / recentPeakPrice * 100.0 // 양수로 표현 (예: 4.5% 하락)
|
||||
|
||||
// 3. 1차 예상 바닥가 (과거 평균 하락폭 적용)
|
||||
// 예: 고점이 10,000원이고 과거 평균 10% 빠졌다면, 예상 바닥은 9,000원
|
||||
val expectedBottomPrice = recentPeakPrice * (1.0 - (reboundStats.avgDropRate / 100.0))
|
||||
|
||||
// 4. 추가 하락 여력 계산 (얼마나 더 빠질 수 있는가?)
|
||||
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
|
||||
|
||||
// 5. 바닥권 진입 판별 (예상 바닥가의 +2% 이내로 들어왔거나, 통계적 마지노선(extremeLow) 근처일 때)
|
||||
val isBottomZone = currentPrice <= (expectedBottomPrice * 1.015) || currentPrice <= (volatility.extremeLow * 1.015)
|
||||
|
||||
return DropPrediction(
|
||||
recentPeakPrice = recentPeakPrice,
|
||||
expectedBottomPrice = expectedBottomPrice,
|
||||
extremeSupportPrice = volatility.extremeLow,
|
||||
currentDropRate = -currentDropRate, // 음수로 표기 (예: -4.5%)
|
||||
remainingDropRate = -remainingDropRate, // 음수면 더 빠질 공간이 남았다는 뜻
|
||||
isBottomZone = isBottomZone
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
@@ -242,7 +242,7 @@ object DatabaseFactory {
|
||||
fun findAllMonitoringTrades(): List<AutoTradeItem> {
|
||||
return transaction(mainDb) {
|
||||
AutoTradeTable.select {
|
||||
AutoTradeTable.status neq "COMPLETED"
|
||||
AutoTradeTable.status notInList listOf(TradeStatus.COMPLETED, TradeStatus.EXPIRED)
|
||||
}.map { mapToAutoTradeItem(it) }
|
||||
}
|
||||
}
|
||||
@@ -614,6 +614,22 @@ object TradingLogStore {
|
||||
}
|
||||
|
||||
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) {
|
||||
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
|
||||
decisionLogs.add(
|
||||
@@ -623,28 +639,24 @@ object TradingLogStore {
|
||||
decision = "NOTICE",
|
||||
confidence = 100.0,
|
||||
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>()
|
||||
fun addNotice(name : String, code : String, log: String, qty: Int? = null) {
|
||||
synchronized(this) {
|
||||
|
||||
@@ -254,6 +254,20 @@ class TradeConfig {
|
||||
var plusFilter : Double = 15.0
|
||||
var excuteCountOnMin : Int = 2
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,21 @@ cntg_vol : $cntg_vol
|
||||
acml_tr_pbmn : $acml_tr_pbmn
|
||||
""".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
|
||||
data class OverseasCandleData(
|
||||
|
||||
@@ -3,6 +3,8 @@ package model
|
||||
import AutoTradeItem
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Serializable
|
||||
data class StockBalanceResponse(
|
||||
val rt_cd: String = "",
|
||||
@@ -106,6 +108,14 @@ enum class RankingType(
|
||||
HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", emptyMap())
|
||||
}
|
||||
|
||||
data class UpcomingDividend(
|
||||
val hasDividend: Boolean,
|
||||
val stockCode: String,
|
||||
val stockName: String,
|
||||
val exDividendDate: String?,
|
||||
val dividendAmount: BigDecimal?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RankingStock(
|
||||
val hts_kor_isnm: String = "", // 종목명
|
||||
@@ -118,9 +128,9 @@ data class RankingStock(
|
||||
val mrkt_div_cls_code : String = "J",
|
||||
) {
|
||||
val name : String
|
||||
get() = listOf(hts_kor_isnm , hts_kor_alph_nm , mkrtc_objt_iscd).firstOrNull { it.isNotBlank() } ?: ""
|
||||
get() = listOf(hts_kor_isnm , hts_kor_alph_nm).firstOrNull { it.isNotBlank() } ?: ""
|
||||
val code : String
|
||||
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd , hts_kor_isnm).firstOrNull { it.isNotBlank() } ?: ""
|
||||
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd).firstOrNull { it.isNotBlank() } ?: ""
|
||||
}
|
||||
@Serializable
|
||||
data class OverseasRankingResponse(
|
||||
@@ -163,7 +173,7 @@ data class UnifiedStockHolding(
|
||||
val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt)
|
||||
val pchsAmount: String = "0" // 총 매입금액 (pchs_amt)
|
||||
) {
|
||||
val isTodayEntry: Boolean get() = thdtBuyQty.toIntOrNull() ?: 0 > 0
|
||||
val isTodayEntry: Boolean get() = (thdtBuyQty.toIntOrNull() ?: 0) > 0
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -43,6 +43,12 @@ class TradingDecision {
|
||||
var financialData : String? = null
|
||||
var analyzer : TechnicalAnalyzer? = 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() =
|
||||
listOf<Double>(ultraShortScore,
|
||||
@@ -60,7 +66,8 @@ class TradingDecision {
|
||||
longTermScore).average()
|
||||
|
||||
|
||||
fun summary() : String{
|
||||
fun summary(
|
||||
targetProfitRate: Double) : String{
|
||||
return """
|
||||
$corpName[$stockName]
|
||||
수익실현 가능성 : ${profitPossible()}
|
||||
@@ -75,6 +82,8 @@ financialScore: $financialScore
|
||||
newsScore: $newsScore
|
||||
decision: $decision
|
||||
reason: $reason
|
||||
예측가능 수익율 : ${maxRealisticProfitRate}
|
||||
반등 주기 가이드: ${analyzer?.generateMTFReboundGuide(targetProfitRate)}
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -93,6 +102,7 @@ reason: $reason
|
||||
confidence: $confidence
|
||||
기술 분석: $techSummary
|
||||
뉴스 점수: $newsScore
|
||||
반등 주기 가이드: $reboundGuideMessage
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ object KisTradeService {
|
||||
val body = response.body<JsonObject>()
|
||||
// output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일
|
||||
val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y"
|
||||
Result.success(!isOpeningDay)
|
||||
Result.success(isOpeningDay)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
@@ -122,6 +122,7 @@ object KisTradeService {
|
||||
} else 0.0
|
||||
|
||||
// 3. 모델 생성
|
||||
if (combinedHoldings.isNotEmpty()) {
|
||||
Result.success(UnifiedBalance(
|
||||
totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)),
|
||||
deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L),
|
||||
@@ -130,7 +131,9 @@ object KisTradeService {
|
||||
totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달
|
||||
holdings = combinedHoldings
|
||||
))
|
||||
|
||||
} else {
|
||||
Result.failure(Exception("combinedHoldings empty"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
@@ -224,6 +227,78 @@ object KisTradeService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchUpcomingDividend(
|
||||
stockCode: String
|
||||
): Result<UpcomingDividend> {
|
||||
|
||||
val config = KisSession.config
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
val fromDate = LocalDate.now().format(formatter)
|
||||
val toDate = LocalDate.now().plusMonths(6).format(formatter)
|
||||
|
||||
return try {
|
||||
val response = client.get("$prodUrl/uapi/domestic-stock/v1/ksdinfo/dividend") {
|
||||
header("authorization", "Bearer ${config.marketToken}")
|
||||
header("appkey", config.realAppKey)
|
||||
header("appsecret", config.realSecretKey)
|
||||
header("tr_id", "HHKDB669102C0")
|
||||
header("custtype", "P")
|
||||
|
||||
// 실제 API 문서의 파라미터명으로 변경
|
||||
parameter("CTS", "")
|
||||
parameter("GB1", "0")
|
||||
parameter("SHT_CD", stockCode)
|
||||
parameter("HIGH_GB", "")
|
||||
parameter("F_DT", fromDate)
|
||||
parameter("T_DT", toDate)
|
||||
}
|
||||
|
||||
val body = response.body<JsonObject>()
|
||||
|
||||
if (body["rt_cd"]?.jsonPrimitive?.content != "0") {
|
||||
return Result.failure(
|
||||
Exception(body["msg1"]?.jsonPrimitive?.content ?: "배당 조회 실패")
|
||||
)
|
||||
}
|
||||
|
||||
val output = body["output1"]?.jsonArray.orEmpty()
|
||||
|
||||
val item = output
|
||||
.map { it.jsonObject }
|
||||
.firstOrNull {
|
||||
it["sht_cd"]?.jsonPrimitive?.content == stockCode
|
||||
}
|
||||
println("fetchUpcomingDividend 배당 body >>> ${item}")
|
||||
if (item == null) {
|
||||
return Result.success(
|
||||
UpcomingDividend(
|
||||
hasDividend = false,
|
||||
stockCode = stockCode,
|
||||
stockName = "",
|
||||
exDividendDate = null,
|
||||
dividendAmount = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
UpcomingDividend(
|
||||
hasDividend = true,
|
||||
stockCode = item["sht_cd"]?.jsonPrimitive?.content.orEmpty(),
|
||||
stockName = item["isin_name"]?.jsonPrimitive?.content.orEmpty(),
|
||||
exDividendDate = item["record_date"]?.jsonPrimitive?.content,
|
||||
dividendAmount = item["per_sto_divi_amt"]?.jsonPrimitive?.content?.toBigDecimalOrNull()
|
||||
)
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [추가] 기간별(일/주/월) 차트 데이터 조회
|
||||
* @param periodCode "D"(일), "W"(주), "M"(월)
|
||||
@@ -234,8 +309,8 @@ object KisTradeService {
|
||||
isDomestic: Boolean = true
|
||||
): Result<List<CandleData>> {
|
||||
val config = KisSession.config
|
||||
val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
val path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
|
||||
|
||||
val today = LocalDate.now()
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
@@ -244,7 +319,7 @@ object KisTradeService {
|
||||
// [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전)
|
||||
// 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다.
|
||||
val startDate = when (periodCode) {
|
||||
"D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분
|
||||
"D" -> today.minusDays(90).format(formatter) // 일봉: 6개월치면 100개 충분
|
||||
"W" -> today.minusYears(2).format(formatter) // 주봉: 2년치
|
||||
"M" -> today.minusYears(8).format(formatter) // 월봉: 8년치
|
||||
else -> today.minusYears(1).format(formatter)
|
||||
@@ -607,8 +682,8 @@ object KisTradeService {
|
||||
println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)")
|
||||
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
|
||||
header("authorization", "Bearer ${config.tradeToken}")
|
||||
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
|
||||
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
||||
header("appkey", config.realAppKey)
|
||||
header("appsecret", config.realSecretKey)
|
||||
header("tr_id", trId)
|
||||
header("tr_cont", trCont)
|
||||
|
||||
@@ -627,11 +702,15 @@ object KisTradeService {
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}")
|
||||
return Result.failure(Exception("HTTP Error: ${response.status}"))
|
||||
if (allHoldings.isNotEmpty() && totalBalance != null) {
|
||||
return Result.success(totalBalance.copy(output1 = allHoldings))
|
||||
}
|
||||
return Result.failure(Exception("HTTP Error: ${response.status} ${response.body<String>()}"))
|
||||
}
|
||||
|
||||
val body = response.body<StockBalanceResponse>()
|
||||
println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
|
||||
println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}\n${body.output2}\n\n")
|
||||
// println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
|
||||
|
||||
allHoldings.addAll(body.output1)
|
||||
if (totalBalance == null) totalBalance = body
|
||||
@@ -647,7 +726,7 @@ object KisTradeService {
|
||||
pageCount++
|
||||
trCont = "N"
|
||||
println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...")
|
||||
delay(250) // API 과부하 방지
|
||||
delay(500) // API 과부하 방지
|
||||
}
|
||||
|
||||
} while (trCont == "N")
|
||||
|
||||
@@ -193,7 +193,7 @@ object KisWebSocketManager {
|
||||
// AES 복호화 실행
|
||||
val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv)
|
||||
val dataRows = decryptedData.split("^")
|
||||
println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}주 ${if(dataRows[13] == "01"){"체결"}else{"접수"} }")
|
||||
println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}주 ${if(dataRows[13] == "2") {"체결"} else {"접수"} }")
|
||||
|
||||
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
|
||||
onExecutionReceived?.invoke(
|
||||
|
||||
@@ -250,7 +250,7 @@ object RagService {
|
||||
|
||||
if (isSafetyBeltStockCodes.contains(stockCode)) {
|
||||
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
|
||||
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
|
||||
println("재무 안정성 부족 (캐시)")
|
||||
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
|
||||
return@coroutineScope
|
||||
}
|
||||
@@ -278,8 +278,8 @@ object RagService {
|
||||
return@coroutineScope
|
||||
}
|
||||
|
||||
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
|
||||
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
|
||||
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 40) {
|
||||
logTime(stockName, "기술 점수 미달 조기 종료 ${tradingDecision.signalModel?.compositeScore} , ${tradingDecision.signalModel?.successProbPct} ", techDuration, System.currentTimeMillis() - totalStartTime)
|
||||
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) {
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true)
|
||||
result(tradingDecision.apply {
|
||||
@@ -331,6 +331,7 @@ object RagService {
|
||||
result(finalDecision, true)
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
println("❌ [$stockName] 분석 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
@@ -560,6 +561,7 @@ object RagService {
|
||||
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
||||
|
||||
return TradingDecision().apply {
|
||||
this.analyzer = tempDecision.analyzer
|
||||
this.technicalScore = techScore100
|
||||
this.financialScore = finScore100
|
||||
this.systemScore = sysScore100
|
||||
|
||||
@@ -103,32 +103,6 @@ object LocalReportGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
fun generateAndOpenAsyncDirectly(
|
||||
summary: RawSummaryData,
|
||||
rawHoldings: List<RawHoldingData>,
|
||||
rawTrades: List<RawTradeData>
|
||||
) {
|
||||
reportScope.launch {
|
||||
try {
|
||||
// 1. [핵심] 대시보드 통계 지표 추출 (Generator가 직접 계산)
|
||||
val stats = calculateDashboardStats(rawHoldings, rawTrades)
|
||||
|
||||
// 2. 탭 2 & 3 HTML 가공
|
||||
val holdingsHtml = processHoldings(rawHoldings)
|
||||
val tradesHtml = processTrades(rawTrades)
|
||||
|
||||
// 3. 전체 HTML 조립
|
||||
val htmlContent = buildHtml(summary, stats, holdingsHtml, tradesHtml)
|
||||
if (summary.type.equals("END", true) || summary.type.equals("MIDDLE", true)) {
|
||||
saveAndOpen(summary.type, htmlContent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ [Report] 리포트 비동기 생성 중 오류 발생: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- [새로운 통계 계산 로직] ---
|
||||
private fun calculateDashboardStats(holdings: List<RawHoldingData>, trades: List<RawTradeData>): DashboardStats {
|
||||
val tradesByStock = trades.groupBy { it.stockCode }
|
||||
|
||||
@@ -59,11 +59,10 @@ object TradingReportManager : TradingReportService {
|
||||
private val activePositions = mutableMapOf<String, String>()
|
||||
|
||||
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
|
||||
// if (!KisSession.tradeConfig.useAutoRepost) {
|
||||
// return
|
||||
// }
|
||||
if (!KisSession.tradeConfig.useAutoRepost) {
|
||||
return
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
println("❌ [Report] 리포트 비동기 생성 중 오류 발생: gggg")
|
||||
val todayDate = LocalDate.now().toString()
|
||||
|
||||
// 1. 중복 없는 전체 종목 코드 리스트 추출
|
||||
@@ -233,7 +232,7 @@ object TradingReportManager : TradingReportService {
|
||||
}
|
||||
|
||||
// 6. 코루틴 기반 제너레이터 호출
|
||||
LocalReportGenerator.generateAndOpenAsyncDirectly(summaryData, holdingLogs, tradeLogs)
|
||||
LocalReportGenerator.generateAndOpenAsync(summaryData, holdingLogs, tradeLogs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package service
|
||||
|
||||
import AutoTradeItem
|
||||
import Defines.AUTOSELL
|
||||
import Defines.BLACKLISTEDSTOCKCODES
|
||||
import Defines.EMBEDDING_PORT
|
||||
import Defines.LLM_PORT
|
||||
import TradingLogStore
|
||||
import TradingLogStore.noticeFilter
|
||||
import analyzer.AdvancedTradeAssistant
|
||||
import analyzer.TechnicalAnalyzer
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,8 +37,9 @@ import network.KisAuthService
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import network.RagService
|
||||
import network.RagService.isSafetyBeltStockCodes
|
||||
import network.StockUniverseLoader
|
||||
import report.SnapshotType
|
||||
import okhttp3.internal.wait
|
||||
import report.TradingReportManager
|
||||
import util.MarketUtil
|
||||
import java.time.LocalDate
|
||||
@@ -48,6 +49,8 @@ import java.time.ZoneId
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.collections.List
|
||||
import kotlin.collections.filter
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
// service/AutoTradingManager.kt
|
||||
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
|
||||
@@ -82,20 +85,18 @@ object AutoTradingManager {
|
||||
fun startBackgroundScheduler() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val seoulZone = ZoneId.of("Asia/Seoul")
|
||||
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
||||
val nowDate = LocalDate.now(seoulZone)
|
||||
var checkTime = 60_000 * 3L
|
||||
val isTradingDay = nowDate.dayOfWeek.value in 1..5
|
||||
val isTradingDay = MarketUtil.canTradeToday()
|
||||
if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) {
|
||||
shouldShowFullWindow = true
|
||||
SystemSleepPreventer.wakeDisplay()
|
||||
} else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) {
|
||||
SystemSleepPreventer.sleepDisplay()
|
||||
}
|
||||
if (!isTradingDay) {
|
||||
checkTime = 60_000 * 30L
|
||||
// SystemSleepPreventer.wakeDisplay()
|
||||
} else if ((now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0)))) {
|
||||
// SystemSleepPreventer.sleepDisplay()
|
||||
}
|
||||
// if (!isTradingDay) {
|
||||
// checkTime = 60_000 * 30L
|
||||
// }
|
||||
delay(checkTime) // 1분마다 체크
|
||||
}
|
||||
}
|
||||
@@ -107,23 +108,47 @@ object AutoTradingManager {
|
||||
if (KisSession.isAvailBuyTime(now) && isSuccess && completeTradingDecision != null) {
|
||||
val decision = completeTradingDecision
|
||||
|
||||
println("${decision.stockName} ${decision.decision}")
|
||||
// 1. 이미 AI가 결정한 decision과 confidence를 신뢰함
|
||||
if (decision.decision == "BUY") {
|
||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||
|
||||
var maxRealisticProfitRate = 0.0
|
||||
// AI가 이미 검증한 등급을 사용 (재계산 불필요)
|
||||
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. 최종 매수 실행
|
||||
val gradeRate = KisSession.config.getValues(grade.allocationRate)
|
||||
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * gradeRate
|
||||
val calculatedQty = (maxBudget / decision.currentPrice).toInt().coerceAtLeast(1)
|
||||
TradingLogStore.addLog(decision,"BUY",decision.summary())
|
||||
decision.maxRealisticProfitRate = maxRealisticProfitRate
|
||||
TradingLogStore.addLog(decision,"BUY",decision.summary(KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide)))
|
||||
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(
|
||||
decision = decision,
|
||||
orderQty = calculatedQty.toString(),
|
||||
profitRate1 = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide),
|
||||
investmentGrade = grade
|
||||
profitRate1 = finalProfitRate,
|
||||
investmentGrade = grade,
|
||||
hasCode = hasCodes == true
|
||||
)
|
||||
} else if (decision.decision.equals("RETRY") || decision.confidence >= 60.0) { // 아까운 종목만 재분석
|
||||
addToReanalysis(RankingStock(decision.stockCode, decision.stockName))
|
||||
@@ -202,7 +227,7 @@ object AutoTradingManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
|
||||
fun excuteTrade(decision: TradingDecision, orderQty: String, profitRate1: Double?, investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK, hasCode: Boolean) {
|
||||
scope.launch {
|
||||
var basePrice = decision.currentPrice
|
||||
val tickSize = MarketUtil.getTickSize(basePrice)
|
||||
@@ -213,27 +238,43 @@ object AutoTradingManager {
|
||||
val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt()
|
||||
|
||||
if (!canAddNewPosition(maxStocks)) {
|
||||
println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.")
|
||||
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
|
||||
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
||||
addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
||||
TradingLogStore.addWatchLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
|
||||
} else if (KisSession.isAvailBuyTime(LocalTime.now())){
|
||||
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
|
||||
|
||||
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
|
||||
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice hasStocks : ${stockCode.contains(stockCode)}" )
|
||||
var realOrderQty = orderQty
|
||||
KisTradeService.postOrder(stockCode, realOrderQty, finalPrice.toLong().toString(), isBuy = true)
|
||||
.onSuccess { realOrderNo ->
|
||||
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
|
||||
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
|
||||
TradingLogStore.addLog(
|
||||
decision,
|
||||
"BUY",
|
||||
"[${investmentGrade.displayName}] 주문 성공: $realOrderNo"
|
||||
)
|
||||
|
||||
val sRate = -1.5
|
||||
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||
val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
|
||||
val effectiveProfitRate =
|
||||
(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 inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
||||
|
||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||
DatabaseFactory.saveAutoTrade(
|
||||
AutoTradeItem(
|
||||
orderNo = realOrderNo,
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
@@ -244,7 +285,8 @@ object AutoTradingManager {
|
||||
stopLossPrice = calculatedStop,
|
||||
status = "PENDING_BUY",
|
||||
isDomestic = true
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
TradingReportManager.recordTradeDecision(
|
||||
orderNo = realOrderNo,
|
||||
@@ -255,20 +297,38 @@ object AutoTradingManager {
|
||||
reason = decision.reason ?: "", // AI 이유
|
||||
decision = decision // AI 객체 통째로 전달
|
||||
)
|
||||
|
||||
if (!hasCode) {
|
||||
syncAndExecute(realOrderNo)
|
||||
|
||||
}
|
||||
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
|
||||
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
|
||||
TradingLogStore.addLog(
|
||||
decision,
|
||||
"BUY",
|
||||
"[${investmentGrade.displayName}] 매수 및 감시 설정 완료 (목표 수익률: ${
|
||||
String.format(
|
||||
"%.4f",
|
||||
effectiveProfitRate
|
||||
)
|
||||
}%): $realOrderNo"
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
|
||||
|
||||
if (it.message?.contains("주문가능금액을 초과") == true) {
|
||||
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
||||
TradingLogStore.addWatchLog(decision,"WATCH","${it.message ?: " 매수 실패"} => 재분석 대기열에 추가")
|
||||
AutoTradingManager.addToReanalysis(
|
||||
RankingStock(
|
||||
mksc_shrn_iscd = stockCode,
|
||||
hts_kor_isnm = stockName
|
||||
)
|
||||
)
|
||||
TradingLogStore.addWatchLog(
|
||||
decision,
|
||||
"WATCH",
|
||||
"${it.message ?: " 매수 실패"} => 재분석 대기열에 추가"
|
||||
)
|
||||
} else {
|
||||
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
|
||||
TradingLogStore.addLog(decision, "BUY", it.message ?: "매수 실패")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -288,6 +348,7 @@ object AutoTradingManager {
|
||||
var onExecutionReceived : ((String, String, String, String, Boolean) -> Unit)? = {code, qty, price,orderNo, isBuy ->
|
||||
scope.launch {
|
||||
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
|
||||
println("exec >> ${exec}")
|
||||
executionCache[orderNo] = exec
|
||||
syncAndExecute(orderNo)
|
||||
}
|
||||
@@ -298,6 +359,7 @@ object AutoTradingManager {
|
||||
if (processingIds.contains(orderNo)) return
|
||||
processingIds.add(orderNo)
|
||||
|
||||
|
||||
try {
|
||||
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||
val execData = executionCache[orderNo]
|
||||
@@ -305,12 +367,15 @@ object AutoTradingManager {
|
||||
if (dbItem != null && execData != null && execData.isFilled) {
|
||||
if (dbItem.status == TradeStatus.PENDING_BUY) {
|
||||
// ✅ 1. 진짜 사온 가격 (실제 매수 체결가)
|
||||
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
|
||||
var actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
|
||||
|
||||
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
|
||||
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
|
||||
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 finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
|
||||
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
||||
@@ -341,13 +406,12 @@ object AutoTradingManager {
|
||||
// ✅ 2. 매도 완료 시점 (실제 매도 체결가)
|
||||
val actualSellPrice = execData.price.toDoubleOrNull() ?: 0.0
|
||||
val actualSellQty = execData.qty.toIntOrNull() ?: dbItem.quantity
|
||||
|
||||
// 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록
|
||||
TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty)
|
||||
|
||||
println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}")
|
||||
TradingLogStore.addSellLog(dbItem.name,actualSellPrice.toString(),"SELL","매도 완료")
|
||||
|
||||
myOredsAndBalanceCodes.remove(dbItem.code)
|
||||
TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림
|
||||
|
||||
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||
@@ -362,12 +426,17 @@ object AutoTradingManager {
|
||||
/**
|
||||
* 자동 발굴 루프 시작 및 Watchdog 실행
|
||||
*/
|
||||
fun startAutoDiscoveryLoop() {
|
||||
fun startAutoDiscoveryLoop(doStart : Boolean = false) {
|
||||
if (isRunning()) return
|
||||
|
||||
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
|
||||
watchdogJob?.cancel()
|
||||
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) {
|
||||
delay(WATCHDOG_CHECK_INTERVAL)
|
||||
val now = System.currentTimeMillis()
|
||||
@@ -392,7 +461,9 @@ object AutoTradingManager {
|
||||
"거랙 차단 대상 : ${holding.currentPrice}[${holding.quantity}주] 보유, 수익률(${holding.profitRate.toDouble()})"
|
||||
)
|
||||
} else {
|
||||
val targetProfitLimit = if (holding.isTodayEntry) {
|
||||
val now = LocalTime.now()
|
||||
|
||||
val targetProfitLimit = if (holding.isTodayEntry && now.isBefore(LocalTime.of(16, 0))) {
|
||||
// 당일 매수 종목: 짧은 익절 (예: 1.0% 이상이면 즉시 매도)
|
||||
KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)+ KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||
} else {
|
||||
@@ -453,32 +524,11 @@ object AutoTradingManager {
|
||||
&& 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)}")
|
||||
val profit = holding.profitRate.toDouble()
|
||||
// TradingLogStore.addNotice(
|
||||
// "보유주식[${holding.name}]",
|
||||
// holding.code,
|
||||
// "수익률($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(
|
||||
TradingLogStore.addNotice(
|
||||
"보유주식[${holding.name}]",
|
||||
holding.code,
|
||||
targetPrice.toString(),
|
||||
"SELL",
|
||||
"☠️ 보유 주식 손절 처리 [수익률 : ${profit}%] ${holding.valuationProfitAmount} 손해 중이며 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"}에 손절가이드에 따라 매매 주문 완료."
|
||||
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함."
|
||||
)
|
||||
}.onFailure { err->
|
||||
println("✅ [${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 손절가이드에 따라 매매 주문 실패] ${holding.name}: $err")
|
||||
}
|
||||
}
|
||||
analyzeDeepLossHoldingsAfterMarket(holding)
|
||||
}
|
||||
@@ -514,9 +564,7 @@ object AutoTradingManager {
|
||||
targetPrice = targetPrice
|
||||
isBefore930 = true
|
||||
} else {
|
||||
targetPrice = MarketUtil.roundToTickSize(
|
||||
targetPrice + MarketUtil.getTickSize(targetPrice)
|
||||
)
|
||||
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
|
||||
}
|
||||
println("🔄 [보유 주식 주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
|
||||
tradeService.postOrder(
|
||||
@@ -532,8 +580,7 @@ object AutoTradingManager {
|
||||
"SELL",
|
||||
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
|
||||
)
|
||||
DatabaseFactory.saveAutoTrade(
|
||||
AutoTradeItem(
|
||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||
orderNo = newOrderNo,
|
||||
code = holding.code,
|
||||
name = holding.name,
|
||||
@@ -544,8 +591,7 @@ object AutoTradingManager {
|
||||
stopLossPrice = 0.0,
|
||||
status = "SELLING",
|
||||
isDomestic = true
|
||||
)
|
||||
)
|
||||
))
|
||||
syncAndExecute(newOrderNo)
|
||||
}.onFailure {
|
||||
TradingLogStore.addSellLog(
|
||||
@@ -556,7 +602,35 @@ object AutoTradingManager {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (KisSession.config.stop_Loss
|
||||
var errMsg = ""
|
||||
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.availOrderCount.toInt() > 0
|
||||
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)
|
||||
@@ -564,9 +638,9 @@ object AutoTradingManager {
|
||||
&& 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)}")
|
||||
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(
|
||||
stockCode = holding.code,
|
||||
@@ -575,21 +649,15 @@ object AutoTradingManager {
|
||||
isBuy = false,
|
||||
).onSuccess { newOrderNo ->
|
||||
println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.")
|
||||
TradingLogStore.addSellLog(
|
||||
holding.code,
|
||||
targetPrice.toString(),
|
||||
"SELL",
|
||||
"☠️ 보유 주식 손절 처리 [수익률 : ${profit}%] ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
|
||||
)
|
||||
}.onFailure { err->
|
||||
println("✅ [보유 주식 손절 처리] 실패 ${err.message}")
|
||||
}
|
||||
|
||||
// TradingLogStore.addNotice(
|
||||
// "보유주식[${holding.name}]",
|
||||
// holding.code,
|
||||
// "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
|
||||
// )
|
||||
TradingLogStore.addNotice(
|
||||
"보유주식[${holding.name}]",
|
||||
holding.code,
|
||||
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도."
|
||||
)
|
||||
}
|
||||
analyzeDeepLossHoldingsAfterMarket(holding , true)
|
||||
}
|
||||
@@ -672,7 +740,7 @@ object AutoTradingManager {
|
||||
|
||||
|
||||
// 현재 노출 수가 최대 허용치보다 작을 때만 true(매수 가능) 반환
|
||||
return true//myOredsAndBalanceCodes.size < maxAllowedStocks
|
||||
return true //(currentBalance?.getHoldings()?.count { it.availOrderCount.toInt() > 0 } ?: 0) > maxAllowedStocks
|
||||
}
|
||||
|
||||
suspend fun tryRefreshToken() {
|
||||
@@ -742,8 +810,9 @@ object AutoTradingManager {
|
||||
}
|
||||
withTimeout(CYCLE_TIMEOUT) {
|
||||
println("⏱️ [Cycle Start] ${LocalTime.now()}")
|
||||
if (now.isAfter(KisSession.endTime())) {
|
||||
executeClosingLiquidation(KisTradeService)
|
||||
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
|
||||
if (now.isAfter(KisSession.endBuyTime()) && activeTrades.isNotEmpty()) {
|
||||
executeClosingLiquidation(activeTrades)
|
||||
} else {
|
||||
executeMarketLoop()
|
||||
}
|
||||
@@ -776,9 +845,8 @@ object AutoTradingManager {
|
||||
isSystemReadyToday = false
|
||||
shouldShowFullWindow = false
|
||||
stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐)
|
||||
} else if (now.isAfter(KisSession.startTime().minusMinutes(10)) && now.isBefore(KisSession.startTime()) && !isSystemReadyToday) {
|
||||
} else if (now.isAfter(KisSession.startTime().minusMinutes(20)) && now.isBefore(KisSession.startTime()) && !shouldShowFullWindow) {
|
||||
if (MarketUtil.canTradeToday()) {
|
||||
SystemSleepPreventer.wakeDisplay()
|
||||
shouldShowFullWindow = true
|
||||
println("✅ [System] 오늘은 영업일입니다. 시스템을 가동합니다.")
|
||||
tryRefreshToken() // 토큰 갱신 및 화면 표시 신호(shouldShowFullWindow = true)
|
||||
@@ -789,28 +857,31 @@ object AutoTradingManager {
|
||||
}
|
||||
}
|
||||
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 myOredsAndBalanceCodes : MutableSet<String> = mutableSetOf()
|
||||
suspend fun checkBalance(isMorning: Boolean = true) {
|
||||
if (isMorning) {
|
||||
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
|
||||
private var lastFetchTime: Long = 0L // 마지막 성공 시간 (Millisecond)
|
||||
private val FETCH_INTERVAL = 2 * 60 * 1000 // 30분을 밀리초로 환산 (1800000 ms)
|
||||
|
||||
suspend fun checkBalance() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentBalance == null || (currentTime - lastFetchTime) > FETCH_INTERVAL) {
|
||||
KisTradeService.fetchIntegratedBalance().getOrNull()?.let {
|
||||
currentBalance = it
|
||||
lastFetchTime = currentTime // 호출 성공 시 현재 시간으로 갱신
|
||||
println("잔고 동기화 완료")
|
||||
} ?: run {
|
||||
println("잔고 조회 실패 (네트워크 오류 등)")
|
||||
}
|
||||
|
||||
} 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() }
|
||||
} else {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun checkAndCancelPendingBuyOrders(
|
||||
@@ -875,48 +946,78 @@ object AutoTradingManager {
|
||||
|
||||
|
||||
suspend fun executeMarketLoop() {
|
||||
myOredsAndBalanceCodes.clear()
|
||||
checkBalance()
|
||||
val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
||||
val myHoldings = currentBalance?.getHoldings()?.map {
|
||||
myOredsAndBalanceCodes.add(it.code)
|
||||
it.code }?.toSet() ?: emptySet()
|
||||
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
|
||||
myOredsAndBalanceCodes.add(it.code)
|
||||
it.code
|
||||
}
|
||||
|
||||
var myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong()
|
||||
myCash = max(myCash,KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX).toLong())
|
||||
val myHoldings = currentBalance?.getHoldings()?.filter { !it.isTodayEntry }?.map { it.code }?.toSet() ?: emptySet()
|
||||
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { it.code }
|
||||
var now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
||||
if (remainingCandidates.isEmpty()) {
|
||||
if (loadedTops.size < 100) {
|
||||
if (loadedTops.size < defaultStockCount) {
|
||||
loadedTops.addAll(StockUniverseLoader.loadUniverse())
|
||||
loadedTops.shuffle()
|
||||
println("✅ 총 ${loadedTops.size}개의 종목이 로드되있음.")
|
||||
}
|
||||
poll100Stocks().forEach { (code, name) ->
|
||||
addToReanalysis(RankingStock(mksc_shrn_iscd = code, hts_kor_isnm = name))
|
||||
loadedTops.shuffle()
|
||||
val count = minOf(loadedTops.size, defaultStockCount)
|
||||
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 {
|
||||
}.filter {
|
||||
val rate = it.prdy_ctrt.toDouble()
|
||||
val corpInfo = DartCodeManager.getCorpCode(it.code)
|
||||
val isOk = (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (KisSession.tradeConfig.minusFilter * -1))
|
||||
val isOk = (rate > 0 && rate < KisSession.tradeConfig.plusFilter) || (rate < 0 && rate > (abs(KisSession.tradeConfig.minusFilter) * -1))
|
||||
|
||||
if (corpInfo?.cName.isNullOrEmpty()) {
|
||||
false
|
||||
} else {
|
||||
} 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 {
|
||||
false
|
||||
}
|
||||
}
|
||||
.filter { !it.name.contains("호스팩", true) }
|
||||
.sortedBy { (it.prdy_ctrt.toDoubleOrNull() ?: 0.0) }
|
||||
.toMutableList()
|
||||
|
||||
if (reanalysisList.isNotEmpty()) {
|
||||
candidates.addAll(reanalysisList)
|
||||
}
|
||||
reanalysisList.clear()
|
||||
remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList}
|
||||
.distinctBy { it.code })
|
||||
if (KisSession.tradeConfig.lowerAveragePrice) {
|
||||
currentBalance?.getHoldings()?.map {
|
||||
if(
|
||||
it.quantity.toInt() > KisSession.tradeConfig.lowerAverageTargetCount &&
|
||||
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 {
|
||||
println("미확인 데이터 ${remainingCandidates.size}")
|
||||
}
|
||||
@@ -939,7 +1040,7 @@ object AutoTradingManager {
|
||||
iterator.remove()
|
||||
}
|
||||
println("남은 후보군 개수 : ${totalCount}")
|
||||
delay(100)
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
sellSchedule()
|
||||
@@ -960,7 +1061,7 @@ object AutoTradingManager {
|
||||
if (now.isBefore(LocalTime.of(8,50)) && now.isAfter(LocalTime.of(8,45))) {
|
||||
cancelAllPendingSellOrders()
|
||||
isExecuted = true
|
||||
} else if ( (now.isBefore(LocalTime.of(16,0)) && now.isAfter(KisSession.endBuyTime())) ) {
|
||||
} else if ( (now.isBefore(LocalTime.of(15,40)) && now.isAfter(KisSession.endBuyTime())) ) {
|
||||
val unfilledResult = KisTradeService.fetchUnfilledOrders()
|
||||
unfilledResult.onSuccess { response ->
|
||||
response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order ->
|
||||
@@ -972,7 +1073,7 @@ object AutoTradingManager {
|
||||
}
|
||||
}
|
||||
isExecuted = true
|
||||
} else if (now.hour == 9) {
|
||||
} else if (now.hour == 9 && now.minute % KisSession.tradeConfig.excuteMinCheck == 0) {
|
||||
TradingLogStore.addAnalyzer(
|
||||
" - ",
|
||||
" - ",
|
||||
@@ -982,7 +1083,11 @@ object AutoTradingManager {
|
||||
println("⏰ [강제 스케줄 실행] 오전 9시 ${currentMinute}분 - 보유주식 매도 체크를 시작합니다.")
|
||||
checkBalance()
|
||||
isExecuted = true
|
||||
} else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt && currentMinute < 45) || (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 1)) {
|
||||
} else if (
|
||||
(
|
||||
(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(
|
||||
" - ",
|
||||
" - ",
|
||||
@@ -1001,7 +1106,10 @@ object AutoTradingManager {
|
||||
isExecuted = true
|
||||
}
|
||||
if (isExecuted) { executionCountMap[timeKey] = currentCount + 1 }
|
||||
if (now.hour >= 20) { executionCountMap.clear() }
|
||||
if (now.hour >= 20) {
|
||||
executionCountMap.clear()
|
||||
noticeFilter.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1026,44 +1134,121 @@ object AutoTradingManager {
|
||||
print("-> 기업명을 못찾아서 제외 | ")
|
||||
return@withTimeout
|
||||
}
|
||||
if(currentBalance?.getHoldings()?.any { it.code.equals(stock.code) && it.quantity.toInt() > 2} == true) {
|
||||
println("물타기 대상 분석")
|
||||
}
|
||||
callback(TradingDecision().apply {
|
||||
this.stockCode = stock.code
|
||||
this.confidence = -1.0
|
||||
this.stockName = stock.name
|
||||
}, false)
|
||||
|
||||
val dailyData = tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
|
||||
val dailyData =
|
||||
tradeService.fetchPeriodChartData(stock.code, "D", true).getOrNull() ?: return@withTimeout
|
||||
val today = dailyData.lastOrNull() ?: null
|
||||
var rate = today?.getFluctuationRate() ?: 0.0
|
||||
val isOk = ((rate < KisSession.tradeConfig.plusFilter) && (rate > (abs(KisSession.tradeConfig.minusFilter) * -1)))
|
||||
println("${stock.name}[${stock.code}] 변동률 : ${rate} , 거래 기준 : ${isOk}")
|
||||
if (today == null) {
|
||||
failList.add(stock.code)
|
||||
print("-> 금일 금액 조회 실패 | ")
|
||||
print("-> 금일 금액 조회 실패 | ${isOk}")
|
||||
return@withTimeout
|
||||
}
|
||||
val currentPrice = today.stck_prpr.toDouble()
|
||||
|
||||
if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
|
||||
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
|
||||
if (!isOk || (myCash > 10L && currentPrice > myCash) || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
|
||||
print("-> [${stock.name}] 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
|
||||
return@withTimeout
|
||||
}
|
||||
|
||||
println("🔍 [분석 진입] ${stock.name} (${LocalTime.now()})")
|
||||
// 🌟 [추가] 고도화된 사전 필터링 (검문소)
|
||||
val tempAnalyzer = TechnicalAnalyzer().apply { this.daily = dailyData }
|
||||
|
||||
// 1. 변동성 기반 수익률 검증 (2% 이상 열려있는가?)
|
||||
println("(dailyData.size * 0.8).toInt() ${(dailyData.size * 0.8).toInt()}")
|
||||
val volatility = tempAnalyzer.calculateVolatilityForecast(dailyData, (dailyData.size * 0.8).toInt())
|
||||
val expectedProfitRate = ((volatility.realisticHigh - currentPrice) / currentPrice) * 100.0
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if (dropPrediction != null) {
|
||||
// 💡 [방어 로직] 아직 바닥까지 한참 남았는데 섣불리 들어가는 것을 방지!
|
||||
// 과거 평균 10% 빠지는 종목인데, 지금 겨우 -3% 빠진 상태라면 (남은 하락폭 -7%)
|
||||
if (dropPrediction.remainingDropRate < -1.5 && !dropPrediction.isBottomZone) {
|
||||
print("-> [${stock.name}] 지하실 주의 (현재 ${"%.1f".format(dropPrediction.currentDropRate)}% 하락, 바닥까지 ${"%.1f".format(dropPrediction.remainingDropRate)}% 추가 하락 위험) | ")
|
||||
return@withTimeout // 매수 후보에서 과감히 제외!
|
||||
}
|
||||
|
||||
// 반대로 완벽한 바닥권(isBottomZone = true)에 들어왔다면 매수 타점으로 인정하여 다음 단계로 넘김
|
||||
}
|
||||
|
||||
if (KisSession.tradeConfig.isUpcomingDividend) {
|
||||
var dividend = KisTradeService.fetchUpcomingDividend(stock.code).getOrNull()
|
||||
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()) }
|
||||
val weekly = async { tradeService.fetchPeriodChartData(stock.code, "W", true).getOrDefault(emptyList()) }
|
||||
val monthly = async { tradeService.fetchPeriodChartData(stock.code, "M", 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()})")
|
||||
}
|
||||
@@ -1110,23 +1295,15 @@ object AutoTradingManager {
|
||||
}
|
||||
|
||||
|
||||
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
|
||||
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
|
||||
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
|
||||
val realHoldings = balanceResult?.getHoldings()?.associateBy { it.code } ?: emptyMap()
|
||||
|
||||
private suspend fun executeClosingLiquidation(activeTrades: List<AutoTradeItem>) {
|
||||
activeTrades.forEach { trade ->
|
||||
try {
|
||||
if (!realHoldings.containsKey(trade.code)) {
|
||||
DatabaseFactory.updateStatusAndOrderNo(trade.id!!, TradeStatus.EXPIRED)
|
||||
return@forEach
|
||||
}
|
||||
// 마감 정리 로직 (필요 시 주석 해제하여 사용)
|
||||
println("📢 [마감 정리 체크] ${trade.name}")
|
||||
} catch (e: Exception) {
|
||||
println("⚠️ [마감 에러] ${trade.name}: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
delay(5)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,13 +50,10 @@ import model.KisSession
|
||||
import network.KisTradeService
|
||||
import network.NewsService
|
||||
import network.StockUniverseLoader
|
||||
import report.SnapshotType
|
||||
import report.TradingReportManager
|
||||
import service.AutoTradingManager
|
||||
import service.AutoTradingManager.currentBalance
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.time.LocalTime
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -108,35 +105,15 @@ fun TradingDecisionLog() {
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||
Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
// index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0))
|
||||
listState.animateScrollToItem(if (filteredLogs.size - 1 >= 0) filteredLogs.size - 1 else 0)
|
||||
listState.animateScrollToItem(filteredLogs.size - 1)
|
||||
}
|
||||
}
|
||||
) { 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(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
@@ -247,9 +224,9 @@ fun TradingDecisionLog() {
|
||||
Text(
|
||||
text = log.decision,
|
||||
color = when (log.decision) {
|
||||
"BUY" -> Color(0xFF800080)
|
||||
"BUY" -> Color.Red
|
||||
"SETTING" -> Color(0xFFFFA500)
|
||||
"SELL" -> if (log.reason.contains("손절 처리")) Color.Blue else Color.Red
|
||||
"SELL" -> Color(0xFF800080)
|
||||
"HOLD" -> Color.DarkGray
|
||||
"ANALYZER" -> Color.Green
|
||||
"PASS" -> Color.Yellow
|
||||
@@ -533,7 +510,28 @@ fun TradingDecisionLog() {
|
||||
Spacer(Modifier.height(16.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 설정들
|
||||
SettingSwitchField(
|
||||
label = "미체결 자동 취소 (매수)",
|
||||
@@ -566,23 +564,37 @@ fun TradingDecisionLog() {
|
||||
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
|
||||
)
|
||||
|
||||
|
||||
SettingSwitchField(
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
SettingSwitchField (
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "장 전 대체마켓 매도",
|
||||
initialChecked = tradeConfig.before_nxt,
|
||||
onCheckedChange = { tradeConfig.before_nxt = it
|
||||
KisSession.saveTradeConfig() }
|
||||
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() }
|
||||
onCheckedChange = {
|
||||
tradeConfig.after_nxt = it
|
||||
KisSession.saveTradeConfig()
|
||||
}
|
||||
)
|
||||
}
|
||||
// SettingSwitchField(
|
||||
// label = "해외 주식",
|
||||
// initialChecked = tradeConfig.enableOverSea,
|
||||
// onCheckedChange = { tradeConfig.enableOverSea = it
|
||||
// KisSession.saveTradeConfig() }
|
||||
// )
|
||||
SettingSwitchField(
|
||||
label = "해외 주식",
|
||||
initialChecked = tradeConfig.enableOverSea,
|
||||
onCheckedChange = { tradeConfig.enableOverSea = it
|
||||
label = "배당 주만 거래",
|
||||
initialChecked = tradeConfig.isUpcomingDividend,
|
||||
onCheckedChange = { tradeConfig.isUpcomingDividend = it
|
||||
KisSession.saveTradeConfig() }
|
||||
)
|
||||
|
||||
@@ -595,6 +607,127 @@ fun TradingDecisionLog() {
|
||||
},
|
||||
helperText = "본인의 텔레그램 아뒤"
|
||||
)
|
||||
SettingSwitchField(
|
||||
label = "물타기",
|
||||
initialChecked = tradeConfig.lowerAveragePrice,
|
||||
onCheckedChange = { tradeConfig.lowerAveragePrice = it
|
||||
KisSession.saveTradeConfig() }
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "물타기 최저선",
|
||||
initialValue = (tradeConfig.lowerAverageMaxRate).toString(),
|
||||
onSave = {
|
||||
tradeConfig.lowerAverageMaxRate = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "이것보다 커야 삼"
|
||||
)
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "물타기 최고선",
|
||||
initialValue = (tradeConfig.lowerAverageMinRate).toString(),
|
||||
onSave = {
|
||||
tradeConfig.lowerAverageMinRate = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "이것보다 작아야 삼"
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "물타기 기준 최소 보유 수량",
|
||||
initialValue = (tradeConfig.lowerAverageTargetCount).toString(),
|
||||
onSave = {
|
||||
tradeConfig.lowerAverageTargetCount = it.toInt()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "이거 이상 갖고 있어야 삼"
|
||||
)
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "물타기 개수",
|
||||
initialValue = (tradeConfig.lowerAverageStockCount).toString(),
|
||||
onSave = {
|
||||
tradeConfig.lowerAverageStockCount = it.toInt()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "1만큼 사고 팜"
|
||||
)
|
||||
}
|
||||
SettingSwitchField(
|
||||
label = "아침 자동 매도 주문",
|
||||
initialChecked = tradeConfig.autoSellOrder,
|
||||
onCheckedChange = { tradeConfig.autoSellOrder = it
|
||||
KisSession.saveTradeConfig() }
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "자동 매도 기준 최저가",
|
||||
initialValue = (tradeConfig.autoSellOrderMin).toString(),
|
||||
onSave = {
|
||||
tradeConfig.autoSellOrderMin = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "이것보다 작아야 주문"
|
||||
)
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "자동 매도 기준 최고가",
|
||||
initialValue = (tradeConfig.autoSellOrderMax).toString(),
|
||||
onSave = {
|
||||
tradeConfig.autoSellOrderMax = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "이것보다 커야 주문"
|
||||
)
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "매입가 기준 호가위로 주문",
|
||||
initialValue = (tradeConfig.autoSellOrderAppend).toString(),
|
||||
onSave = {
|
||||
tradeConfig.autoSellOrderAppend = it.toInt()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "위의 수치 만큼 호가 위로 주문함."
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "예상 수익율",
|
||||
initialValue = (tradeConfig.minExpectedProfitRate).toString(),
|
||||
onSave = {
|
||||
tradeConfig.minExpectedProfitRate = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "현제가 기준 예상 수익율이 더커야 삼"
|
||||
)
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "예상 매도 기준일",
|
||||
initialValue = (tradeConfig.maxExpectedReboundDays).toString(),
|
||||
onSave = {
|
||||
tradeConfig.maxExpectedReboundDays = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "이정도 일수 전에는 팔릴거라 예상"
|
||||
)
|
||||
SettingInputField(
|
||||
modifier = Modifier.weight(1.0f, true),
|
||||
label = "주식 널뛰기 기준(예상 매도 기준일)",
|
||||
initialValue = (tradeConfig.minExpectedReboundDays).toString(),
|
||||
onSave = {
|
||||
tradeConfig.minExpectedReboundDays = it.toDouble()
|
||||
KisSession.saveTradeConfig()
|
||||
},
|
||||
helperText = "너무 작으면 초단타,널뛰기, 이 보다 커야 분석함"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -740,9 +873,61 @@ fun CsvDropZone(
|
||||
}
|
||||
|
||||
|
||||
//@OptIn(ExperimentalMaterialApi::class)
|
||||
//@Composable
|
||||
//fun SettingInputField(
|
||||
// label: String,
|
||||
// initialValue: String, // 💡 value -> initialValue 로 변경
|
||||
// placeholder: String = "",
|
||||
// helperText: String = "",
|
||||
// onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경
|
||||
//) {
|
||||
// // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
|
||||
// var localText by remember { mutableStateOf(initialValue) }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// OutlinedTextField(
|
||||
// value = localText,
|
||||
// onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
|
||||
// label = { Text(label, fontWeight = FontWeight.Bold) },
|
||||
// placeholder = { Text(placeholder) },
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .onFocusChanged { focusState ->
|
||||
// // 💡 포커스를 잃었을 때 (다른 칸을 클릭했을 때) 저장
|
||||
// if (!focusState.isFocused) {
|
||||
// onSave(localText)
|
||||
// }
|
||||
// },
|
||||
// singleLine = true,
|
||||
// keyboardOptions = KeyboardOptions(
|
||||
// imeAction = ImeAction.Done,
|
||||
// keyboardType = KeyboardType.Decimal
|
||||
// ),
|
||||
// keyboardActions = KeyboardActions(
|
||||
// // 💡 모바일 키보드나 키보드에서 엔터(Done) 쳤을 때 저장
|
||||
// onDone = {
|
||||
// onSave(localText)
|
||||
// }
|
||||
// )
|
||||
// )
|
||||
//
|
||||
// if (helperText.isNotEmpty()) {
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
// Text(
|
||||
// text = helperText,
|
||||
// color = Color.Gray,
|
||||
// fontSize = 11.sp,
|
||||
// modifier = Modifier.padding(start = 4.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun SettingInputField(
|
||||
modifier: Modifier? = null,
|
||||
label: String,
|
||||
initialValue: String, // 💡 value -> initialValue 로 변경
|
||||
placeholder: String = "",
|
||||
@@ -752,7 +937,7 @@ fun SettingInputField(
|
||||
// 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
|
||||
var localText by remember { mutableStateOf(initialValue) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = modifier ?: Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = localText,
|
||||
onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
|
||||
@@ -791,8 +976,12 @@ fun SettingInputField(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun SettingSwitchField(
|
||||
modifier :Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 4.dp),
|
||||
label: String,
|
||||
initialChecked: Boolean,
|
||||
helperText: String = "",
|
||||
@@ -802,9 +991,7 @@ fun SettingSwitchField(
|
||||
var localChecked by remember { mutableStateOf(initialChecked) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 4.dp)
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.time.ZoneId
|
||||
|
||||
object MarketUtil {
|
||||
private var isHolidayCached: Boolean? = null // 하루 한 번만 체크하기 위한 캐시
|
||||
|
||||
var canTradeDays = hashMapOf<String, Boolean>()
|
||||
suspend fun canTradeToday(): Boolean {
|
||||
val seoulZone = java.time.ZoneId.of("Asia/Seoul")
|
||||
val now = java.time.ZonedDateTime.now(seoulZone)
|
||||
@@ -16,25 +16,29 @@ object MarketUtil {
|
||||
val dayOfWeek = now.dayOfWeek.value
|
||||
if (dayOfWeek >= 6) return false
|
||||
// 1. 주말 체크 (토, 일)
|
||||
val cachedHoliday = DatabaseFactory.getHoliday(todayStr)
|
||||
if (cachedHoliday != null) {
|
||||
println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(cachedHoliday) "휴장" else "영업일"}")
|
||||
return !cachedHoliday
|
||||
}
|
||||
|
||||
// 3. DB에 없으면 API 호출
|
||||
return try {
|
||||
val result = KisTradeService.fetchIsHoliday(todayStr)
|
||||
val isHoliday = result.getOrDefault(true)
|
||||
|
||||
// 결과를 DB에 저장하여 다음 실행 시 재사용
|
||||
DatabaseFactory.saveHoliday(todayStr, isHoliday)
|
||||
|
||||
println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다.")
|
||||
!isHoliday
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
return true
|
||||
// try {
|
||||
// if (canTradeDays.contains(todayStr)) {
|
||||
// println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}")
|
||||
// return canTradeDays.get(todayStr) == true
|
||||
// }
|
||||
// } catch (e: Exception) {e.printStackTrace()}
|
||||
//
|
||||
//
|
||||
// // 3. DB에 없으면 API 호출
|
||||
// return try {
|
||||
// val result = KisTradeService.fetchIsHoliday(todayStr)
|
||||
// val canTrade = result.getOrDefault(false)
|
||||
//
|
||||
// // 결과를 DB에 저장하여 다음 실행 시 재사용
|
||||
// canTradeDays.put(todayStr, canTrade)
|
||||
//
|
||||
// println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다. ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}" )
|
||||
// canTrade
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// false
|
||||
// }
|
||||
}
|
||||
|
||||
fun isKoreanMarketOpen(): Boolean {
|
||||
|
||||
@@ -11378,5 +11378,21 @@
|
||||
{
|
||||
"code": "408470",
|
||||
"name": "한패스"
|
||||
},
|
||||
{
|
||||
"code": "403810",
|
||||
"name": "아이엘로보틱스"
|
||||
},
|
||||
{
|
||||
"code": "059180",
|
||||
"name": "엔더블유시"
|
||||
},
|
||||
{
|
||||
"code": "950250",
|
||||
"name": "테라뷰"
|
||||
},
|
||||
{
|
||||
"code": "288180",
|
||||
"name": "케이피항공산업"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user