Compare commits
50
Commits
488e4e72b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3827698b6f | ||
|
|
c84f4f0cd1 | ||
|
|
a53beb1e18 | ||
|
|
15793d1852 | ||
|
|
84f2202f17 | ||
|
|
1a2fe8b6e6 | ||
|
|
0e241205ee | ||
|
|
8e567f5b69 | ||
|
|
442cdf0877 | ||
|
|
3998cf159a | ||
|
|
5e90ee39bc | ||
|
|
5882f07e42 | ||
|
|
302802a53d | ||
|
|
b83eb11cb7 | ||
|
|
30ab7bbecf | ||
|
|
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
|
AutoTradingManager.isSystemCleanedUpToday = false
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
AutoTradingManager.startAutoDiscoveryLoop()
|
AutoTradingManager.startAutoDiscoveryLoop(true)
|
||||||
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
|
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
|
||||||
KisWebSocketManager.connect()
|
KisWebSocketManager.connect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package analyzer
|
||||||
|
|
||||||
|
import model.CurrentPriceOutput
|
||||||
|
import model.RiskValidationResult
|
||||||
|
|
||||||
|
object RiskManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통합된 시세 데이터를 바탕으로 매수 불가능한 '위험 종목'을 걸러냅니다.
|
||||||
|
* @param data API에서 받아온 통합 CurrentPriceOutput 객체
|
||||||
|
* @return RiskValidationResult (isSafe == true 이면 매수 진행)
|
||||||
|
*/
|
||||||
|
fun evaluateRisk(data: CurrentPriceOutput): RiskValidationResult {
|
||||||
|
|
||||||
|
// 1. 거래 정지 및 임시 정지 확인
|
||||||
|
if (data.iscd_stat_cls_code == "58" || data.temp_stop_yn.uppercase() == "Y") {
|
||||||
|
return RiskValidationResult(false, "거래정지 또는 임시정지 종목")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 상장폐지 직전 단계 (정리매매, 관리종목)
|
||||||
|
if (data.sltr_yn.uppercase() == "Y") {
|
||||||
|
return RiskValidationResult(false, "정리매매 진행 중인 종목")
|
||||||
|
}
|
||||||
|
if (data.iscd_stat_cls_code == "51" || data.mang_issu_cls_code == "1" || data.mang_issu_cls_code.uppercase() == "Y") {
|
||||||
|
return RiskValidationResult(false, "상장폐지 위험이 있는 관리종목")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 거래소 경고 (투자위험, 투자경고, 투자주의, 단기과열종목)
|
||||||
|
val warningCodes = listOf("52", "53", "54", "59")
|
||||||
|
if (warningCodes.contains(data.iscd_stat_cls_code)) {
|
||||||
|
return RiskValidationResult(false, "거래소 경고 상태 (상태코드: ${data.iscd_stat_cls_code})")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.short_over_yn.uppercase() == "Y") {
|
||||||
|
return RiskValidationResult(false, "단기과열 지정 종목")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.invt_caful_yn.uppercase() == "Y") {
|
||||||
|
return RiskValidationResult(false, "투자유의 지정 종목")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 시장경고코드 필터링 (정상 상태인 "00"이나 빈 값이 아닌 경우)
|
||||||
|
if (data.mrkt_warn_cls_code.isNotEmpty() && data.mrkt_warn_cls_code != "00") {
|
||||||
|
return RiskValidationResult(false, "시장경고 발동 중 (경고코드: ${data.mrkt_warn_cls_code})")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 위험 요소를 무사히 통과한 경우
|
||||||
|
return RiskValidationResult(true, "안전")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,83 @@ class TechnicalAnalyzer {
|
|||||||
|
|
||||||
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
|
fun isValid() = listOf(min30, monthly, weekly, daily).all { it.isNotEmpty() }
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규] 기간별(1M, 6M, 1Y) 최고가 저항선 근접 여부를 판단하여 감점 산출
|
||||||
|
*/
|
||||||
|
fun calculateHighPricePenalty(): Double {
|
||||||
|
if (daily.isEmpty()) return 0.0
|
||||||
|
|
||||||
|
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||||
|
var penalty = 0.0
|
||||||
|
|
||||||
|
// 1. 최근 1달 (일봉 20개) 최고가 대비 감점
|
||||||
|
if (daily.size >= 20) {
|
||||||
|
val max1M = daily.takeLast(20).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
|
||||||
|
// 현재가가 1달 최고가를 뚫었거나 최고가의 98% 이상 바짝 붙었을 때 단기 매물대 저항 감점
|
||||||
|
if (max1M > 0 && currentPrice >= max1M * 0.98) {
|
||||||
|
penalty -= 3.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 최근 6개월 (주봉 26개) 최고가 대비 감점
|
||||||
|
if (weekly.size >= 26) {
|
||||||
|
val max6M = weekly.takeLast(26).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
|
||||||
|
if (max6M > 0 && currentPrice >= max6M * 0.97) {
|
||||||
|
penalty -= 4.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 최근 1년 (주봉 52개 또는 월봉 12개) 최고가 대비 감점
|
||||||
|
if (weekly.size >= 52) {
|
||||||
|
val max1Y = weekly.takeLast(52).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
|
||||||
|
if (max1Y > 0 && currentPrice >= max1Y * 0.95) {
|
||||||
|
penalty -= 5.0
|
||||||
|
}
|
||||||
|
} else if (monthly.size >= 12) { // 주봉이 부족할 경우 월봉으로 대체 대안
|
||||||
|
val max1Y = monthly.takeLast(12).map { it.stck_hgpr.toDouble() }.maxOrNull() ?: 0.0
|
||||||
|
if (max1Y > 0 && currentPrice >= max1Y * 0.95) {
|
||||||
|
penalty -= 5.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return penalty
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규] 단기 낙폭 과대 후 바닥을 다지고 돌아서는 '반등 추세(Turnaround)' 확인 시 가점 산출
|
||||||
|
*/
|
||||||
|
fun calculateReboundBonus(): Double {
|
||||||
|
if (daily.size < 10) return 0.0
|
||||||
|
|
||||||
|
// 최근 10일의 데이터를 쪼개어 흐름 분석 (과거 7일 vs 최근 3일)
|
||||||
|
val past7Days = daily.takeLast(10).take(7)
|
||||||
|
val recent3Days = daily.takeLast(3)
|
||||||
|
|
||||||
|
val pastChange = calculateChange(past7Days) // 이전 7일간의 등락률
|
||||||
|
val recentChange = calculateChange(recent3Days) // 최근 3일간의 등락률
|
||||||
|
|
||||||
|
// 조건: 앞선 7일 동안은 $-3.0\%$ 이하로 밀리며 역배열 혹은 투매가 나왔으나,
|
||||||
|
// 최근 3일간 $+2.5\%$ 이상 강하게 단기 정배열 전환 혹은 양봉 밀집 반등이 일어날 때
|
||||||
|
if (pastChange <= -3.0 && recentChange >= 2.5) {
|
||||||
|
return 8.0 // 반등 성공 가점
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대안 조건: 5일 이동평균선(MA5)의 하락 추세 멈춤 및 상향 턴어라운드(V자 반등) 지점 포착
|
||||||
|
if (daily.size >= 7) {
|
||||||
|
val ma5Today = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||||
|
val ma5Yesterday = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||||
|
val ma5TwoDaysAgo = daily.dropLast(2).takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||||
|
|
||||||
|
// 2일 전까지는 이평선이 내려앉다가 오늘 고개를 드는 변곡점 형태
|
||||||
|
if (ma5Today > ma5Yesterday && ma5Yesterday < ma5TwoDaysAgo) {
|
||||||
|
return 5.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
|
* 기술적 지표와 추세, 그리고 초단기(Micro) 흐름을 결합한 종합 신호 생성
|
||||||
*/
|
*/
|
||||||
@@ -49,8 +126,18 @@ class TechnicalAnalyzer {
|
|||||||
|
|
||||||
// 2. 점수 정교화 (가점/감점 요인)
|
// 2. 점수 정교화 (가점/감점 요인)
|
||||||
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
||||||
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
|
val trendConditions = listOf(
|
||||||
refinedScore += 10.0
|
calculateChange(monthly) > 0, // 장기 추세
|
||||||
|
calculateChange(weekly) > 0, // 중기 추세
|
||||||
|
calculateChange(daily.takeLast(5)) > 0 // 단기 추세
|
||||||
|
)
|
||||||
|
|
||||||
|
val passedCount = trendConditions.count { it == true }
|
||||||
|
|
||||||
|
if (passedCount >= 2) {
|
||||||
|
refinedScore += 10.0 // 2개 이상이 상승 추세면 가점 부여
|
||||||
|
} else if (passedCount == 3) {
|
||||||
|
refinedScore += 15.0 // 3개 모두 일치하면 '초강력 추세'로 보너스 추가 가점 (선택 사항)
|
||||||
}
|
}
|
||||||
|
|
||||||
// [보완] 자금 유입 강도(MFI) 반영
|
// [보완] 자금 유입 강도(MFI) 반영
|
||||||
@@ -67,6 +154,14 @@ class TechnicalAnalyzer {
|
|||||||
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
|
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
|
||||||
if (bodyRange > atr * 1.2) refinedScore += 7.0
|
if (bodyRange > atr * 1.2) refinedScore += 7.0
|
||||||
|
|
||||||
|
// 🌟 [추가 보완 1] 기간별 최고가 저항선 감점 적용
|
||||||
|
val highPricePenalty = calculateHighPricePenalty()
|
||||||
|
refinedScore += highPricePenalty // 음수 값이 반환되므로 가산
|
||||||
|
|
||||||
|
// 🌟 [추가 보완 2] 낙폭 과대 후 단기 반등 추세 가점 적용
|
||||||
|
val reboundBonus = calculateReboundBonus()
|
||||||
|
refinedScore += reboundBonus
|
||||||
|
|
||||||
// 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석
|
// 🚀 [마이크로 분석] 기존 min30 리스트를 재활용하여 최근 5분간의 초단기 흐름 분석
|
||||||
if (min30.size >= 15) {
|
if (min30.size >= 15) {
|
||||||
val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들)
|
val last5Candles = min30.takeLast(5) // 최근 5분(5개 캔들)
|
||||||
@@ -100,31 +195,120 @@ class TechnicalAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직
|
* [신규] 종목의 평균 반등 텀(캔들 수)을 계산합니다.
|
||||||
|
* * @param candles 분석할 캔들 리스트 (daily, weekly 등)
|
||||||
|
* @param dropThreshold 고점 대비 이 비율(%)만큼 떨어지면 하락으로 간주 (기본 5.0%)
|
||||||
|
* @param reboundThreshold 바닥 대비 이 비율(%)만큼 오르면 반등으로 간주 (기본 3.0%)
|
||||||
|
* @return 평균 반등에 소요된 캔들 수 (사이클이 없으면 0.0 반환)
|
||||||
|
*/
|
||||||
|
fun calculateAverageReboundTerm(
|
||||||
|
candles: List<CandleData>,
|
||||||
|
dropThreshold: Double = 5.0,
|
||||||
|
reboundThreshold: Double = 3.0
|
||||||
|
): Double {
|
||||||
|
if (candles.size < 10) return 0.0
|
||||||
|
|
||||||
|
var peakPrice = candles.first().stck_hgpr.toDouble()
|
||||||
|
var bottomPrice = peakPrice
|
||||||
|
var bottomIndex = 0
|
||||||
|
|
||||||
|
var isDropping = false
|
||||||
|
val reboundTerms = mutableListOf<Int>()
|
||||||
|
|
||||||
|
for (i in candles.indices) {
|
||||||
|
val currentHigh = candles[i].stck_hgpr.toDouble()
|
||||||
|
val currentLow = candles[i].stck_lwpr.toDouble()
|
||||||
|
val currentClose = candles[i].stck_prpr.toDouble()
|
||||||
|
|
||||||
|
if (!isDropping) {
|
||||||
|
// 1. 상승/횡보 구간: 고점 갱신 확인
|
||||||
|
if (currentHigh > peakPrice) {
|
||||||
|
peakPrice = currentHigh
|
||||||
|
}
|
||||||
|
// 고점 대비 특정 비율(dropThreshold) 이상 하락하면 하락장 진입으로 판단
|
||||||
|
if (peakPrice > 0 && ((currentClose - peakPrice) / peakPrice * 100) <= -dropThreshold) {
|
||||||
|
isDropping = true
|
||||||
|
bottomPrice = currentLow
|
||||||
|
bottomIndex = i // 바닥(최저점) 후보 인덱스 기록
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2. 하락 구간: 바닥 갱신 확인
|
||||||
|
if (currentLow < bottomPrice) {
|
||||||
|
bottomPrice = currentLow
|
||||||
|
bottomIndex = i
|
||||||
|
}
|
||||||
|
// 바닥 대비 특정 비율(reboundThreshold) 이상 상승하면 반등 완료로 판단
|
||||||
|
if (bottomPrice > 0 && ((currentClose - bottomPrice) / bottomPrice * 100) >= reboundThreshold) {
|
||||||
|
val daysToRebound = i - bottomIndex // 바닥을 찍고 반등하기까지 걸린 캔들 수
|
||||||
|
reboundTerms.add(daysToRebound)
|
||||||
|
|
||||||
|
// 3. 상태 초기화 (다음 하락/반등 사이클을 찾기 위해)
|
||||||
|
isDropping = false
|
||||||
|
peakPrice = currentHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반등 사이클이 한 번이라도 있었다면 평균 캔들 수를 반환
|
||||||
|
return if (reboundTerms.isNotEmpty()) reboundTerms.average() else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 억울한 HOLD를 막아주는 유연한 과열 판별 로직 (주가 및 변동성 기반 세분화)
|
||||||
*/
|
*/
|
||||||
fun isOverheatedStock(): Boolean {
|
fun isOverheatedStock(): Boolean {
|
||||||
if (daily.size < 20) return false
|
if (daily.size < 20) return false
|
||||||
val currentPrice = daily.last().stck_prpr.toDouble()
|
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
// 1. 일봉 20일선 이격도
|
// 1. 기본 지표 계산
|
||||||
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||||
val disparityDaily = (currentPrice / ma20Daily) * 100
|
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||||||
|
|
||||||
// 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인)
|
|
||||||
val rsiDaily = calculateRSI(daily)
|
val rsiDaily = calculateRSI(daily)
|
||||||
|
|
||||||
// 3. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용)
|
// 2. 가격대별(시가총액 및 호가단위 대용) 동적 임계값 세팅
|
||||||
|
val maxDisparity: Double // 절대 진입 금지 (초과열)
|
||||||
|
val warningDisparity: Double // 경고 수준 (보조지표 결합 시 차단)
|
||||||
|
val microPumpThreshold: Double // 10분 내 단기 펌핑 기준
|
||||||
|
|
||||||
|
when {
|
||||||
|
currentPrice >= 50000.0 -> {
|
||||||
|
// [대형주/우량주] 무거워서 20일선 대비 15%만 떠도 역사적 과열
|
||||||
|
maxDisparity = 115.0
|
||||||
|
warningDisparity = 110.0
|
||||||
|
microPumpThreshold = 103.0 // 10분 만에 3% 급등하면 꼭지
|
||||||
|
}
|
||||||
|
currentPrice in 5000.0..49999.0 -> {
|
||||||
|
// [일반 중소형주] 표준적인 변동성
|
||||||
|
maxDisparity = 125.0
|
||||||
|
warningDisparity = 115.0
|
||||||
|
microPumpThreshold = 105.0 // 10분 만에 5% 급등
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// [소형주/동전주] 가벼워서 상한가 한 방에 130% 쉽게 도달
|
||||||
|
maxDisparity = 135.0
|
||||||
|
warningDisparity = 120.0
|
||||||
|
microPumpThreshold = 107.0 // 10분 만에 7% 급등
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 종목 고유 변동성(ATR)을 통한 임계값 미세 보정 (Smart Adjustment)
|
||||||
|
// 평소 하루에 2~3% 움직이는 얌전한 주식이 갑자기 튀면 임계값을 더 빡빡하게 죔
|
||||||
|
val atrPct = (calculateATR(daily) / currentPrice) * 100.0
|
||||||
|
val adjustedMaxDisparity = if (atrPct < 3.0) maxDisparity * 0.96 else maxDisparity
|
||||||
|
val adjustedWarnDisparity = if (atrPct < 3.0) warningDisparity * 0.97 else warningDisparity
|
||||||
|
|
||||||
|
// 4. 초단기(최근 10분) 순간 급등 꼭지 확인 (min30 재활용)
|
||||||
var isMicroOverheated = false
|
var isMicroOverheated = false
|
||||||
if (min30.size >= 10) {
|
if (min30.size >= 10) {
|
||||||
val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average()
|
val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average()
|
||||||
val disparityMin = (currentPrice / ma10Min) * 100
|
val disparityMin = (currentPrice / ma10Min) * 100
|
||||||
isMicroOverheated = disparityMin > 105.0 // 10분 평균가 대비 순간적으로 5% 이상 폭등 시
|
isMicroOverheated = disparityMin > microPumpThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단순히 이격도 115%라고 막는 것이 아니라 3가지 깐깐한 조건 중 하나라도 충족될 때만 과열 판정
|
// 5. 최종 세분화 판정 로직
|
||||||
return disparityDaily > 130.0 || // (A) 역대급 폭등 상태
|
return disparityDaily > adjustedMaxDisparity || // (A) 종목 체급 대비 역대급 폭등 상태
|
||||||
(disparityDaily > 115.0 && rsiDaily > 75.0) || // (B) 급등 중이면서 일봉 과매수
|
(disparityDaily > adjustedWarnDisparity && rsiDaily > 75.0) || // (B) 체급별 경고 수준 + 일봉 과매수
|
||||||
(disparityDaily > 115.0 && isMicroOverheated) // (C) 급등 중이면서 10분 내 순간 펌핑(꼭지)
|
(disparityDaily > adjustedWarnDisparity && isMicroOverheated) // (C) 체급별 경고 수준 + 초단기 펌핑(꼭지)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun calculateScores(financialScore100: Int): InvestmentScores {
|
fun calculateScores(financialScore100: Int): InvestmentScores {
|
||||||
@@ -157,6 +341,48 @@ class TechnicalAnalyzer {
|
|||||||
}
|
}
|
||||||
return trList.average()
|
return trList.average()
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* [신규] 현재 주가가 통계적 반등 주기에 근접했는지 확인합니다.
|
||||||
|
* @param candles 분석할 캔들 리스트 (daily, weekly 등)
|
||||||
|
* @param avgReboundTerm 앞서 계산한 평균 반등 소요 캔들 수
|
||||||
|
* @param dropThreshold 하락장으로 판단할 기준 하락률 (기본 5.0%)
|
||||||
|
* @param timeTolerance 오차 허용 범위 (기본 1.5 -> 평균 주기보다 하루이틀 빠르거나 늦어도 인정)
|
||||||
|
*/
|
||||||
|
fun checkReboundApproaching(
|
||||||
|
candles: List<CandleData>,
|
||||||
|
avgReboundTerm: Double,
|
||||||
|
dropThreshold: Double = 5.0,
|
||||||
|
timeTolerance: Double = 2.0
|
||||||
|
): Boolean {
|
||||||
|
if (candles.size < 20 || avgReboundTerm <= 0.0) return false
|
||||||
|
|
||||||
|
// 1. 최근 20일 내 단기 고점 파악
|
||||||
|
val recentCandles = candles.takeLast(20)
|
||||||
|
var recentPeakPrice = 0.0
|
||||||
|
var daysSincePeak = 0
|
||||||
|
|
||||||
|
for (i in recentCandles.indices.reversed()) {
|
||||||
|
val highPrice = recentCandles[i].stck_hgpr.toDouble()
|
||||||
|
if (highPrice > recentPeakPrice) {
|
||||||
|
recentPeakPrice = highPrice
|
||||||
|
daysSincePeak = (recentCandles.size - 1) - i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentDropRate = (candles.last().stck_prpr.toDouble() - recentPeakPrice) / recentPeakPrice * 100
|
||||||
|
|
||||||
|
// 🌟 2. 3가지 핵심 조건 분리
|
||||||
|
val isPriceDropped = currentDropRate <= -dropThreshold
|
||||||
|
val isPastMinTime = daysSincePeak >= (avgReboundTerm - timeTolerance)
|
||||||
|
|
||||||
|
// 최대 기간 조건은 참고용으로 남겨두되 매수 차단 로직에서는 제외합니다.
|
||||||
|
// val isWithinMaxTime = daysSincePeak <= (avgReboundTerm + (timeTolerance * 2))
|
||||||
|
|
||||||
|
// 🌟 3. 현실적인 타점 판별 (필수 2가지만 강력하게 요구)
|
||||||
|
// 필수 1: 가격이 통계적 하락폭만큼 충분히 빠졌는가? (눌림목 대전제)
|
||||||
|
// 필수 2: 최소한의 반등 준비 기간(평균 기간 - 오차)은 지났는가? (떨어지는 칼날 방지)
|
||||||
|
return isPriceDropped && isPastMinTime
|
||||||
|
}
|
||||||
|
|
||||||
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
|
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
|
||||||
if (candles.size < period + 1) return 50.0
|
if (candles.size < period + 1) return 50.0
|
||||||
@@ -241,4 +467,312 @@ $standardizedScores
|
|||||||
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
|
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 종목의 과거 차트를 분석하여 고유의 반등 통계(평균 주기, 오차 범위, 평균 하락폭)를 도출합니다.
|
||||||
|
*/
|
||||||
|
fun calculateDynamicReboundStats(
|
||||||
|
candles: List<CandleData>,
|
||||||
|
minDropToDetect: Double = 3.0
|
||||||
|
): ReboundStats {
|
||||||
|
if (candles.size < 20) return ReboundStats()
|
||||||
|
|
||||||
|
var peakPrice = candles.first().stck_hgpr.toDouble()
|
||||||
|
var bottomPrice = peakPrice
|
||||||
|
var bottomIndex = 0
|
||||||
|
var isDropping = false
|
||||||
|
|
||||||
|
val reboundTerms = mutableListOf<Int>()
|
||||||
|
val dropRates = mutableListOf<Double>()
|
||||||
|
val reboundAmplitudes = mutableListOf<Double>() // 🌟 [신규] 반등 상승폭 수집
|
||||||
|
|
||||||
|
for (i in candles.indices) {
|
||||||
|
val currentHigh = candles[i].stck_hgpr.toDouble()
|
||||||
|
val currentLow = candles[i].stck_lwpr.toDouble()
|
||||||
|
val currentClose = candles[i].stck_prpr.toDouble()
|
||||||
|
|
||||||
|
if (!isDropping) {
|
||||||
|
if (currentHigh > peakPrice) peakPrice = currentHigh
|
||||||
|
val dropRate = if (peakPrice > 0) ((currentClose - peakPrice) / peakPrice * 100) else 0.0
|
||||||
|
|
||||||
|
if (dropRate <= -minDropToDetect) {
|
||||||
|
isDropping = true
|
||||||
|
bottomPrice = currentLow
|
||||||
|
bottomIndex = i
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (currentLow < bottomPrice) {
|
||||||
|
bottomPrice = currentLow
|
||||||
|
bottomIndex = i
|
||||||
|
}
|
||||||
|
|
||||||
|
val reboundRate = if (bottomPrice > 0) ((currentClose - bottomPrice) / bottomPrice * 100) else 0.0
|
||||||
|
if (reboundRate >= minDropToDetect) { // 3% 이상 반등 시 사이클 종료 및 기록
|
||||||
|
reboundTerms.add(i - bottomIndex)
|
||||||
|
dropRates.add(abs((bottomPrice - peakPrice) / peakPrice * 100))
|
||||||
|
reboundAmplitudes.add(reboundRate) // 🌟 상승폭 기록
|
||||||
|
|
||||||
|
isDropping = false
|
||||||
|
peakPrice = currentHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reboundTerms.size >= 2) {
|
||||||
|
val avgDays = reboundTerms.average()
|
||||||
|
val variance = reboundTerms.map { Math.pow(it - avgDays, 2.0) }.average()
|
||||||
|
val safeTolerance = Math.sqrt(variance).coerceIn(1.0, 3.0)
|
||||||
|
|
||||||
|
return ReboundStats(
|
||||||
|
avgReboundPeriod = avgDays,
|
||||||
|
timeTolerance = safeTolerance,
|
||||||
|
avgDropRate = dropRates.average(),
|
||||||
|
avgReboundAmplitude = reboundAmplitudes.average(), // 🌟 평균 반등폭 반환
|
||||||
|
isValid = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ReboundStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateMTFReboundGuide(
|
||||||
|
targetProfitRate: Double // 시스템 설정에 있는 목표 수익률 (예: 3.0%)
|
||||||
|
): String {
|
||||||
|
// 1. 월, 주, 일봉 통계 추출
|
||||||
|
val monthlyStats = calculateDynamicReboundStats(monthly, minDropToDetect = 10.0)
|
||||||
|
val weeklyStats = calculateDynamicReboundStats(weekly, minDropToDetect = 5.0)
|
||||||
|
val dailyStats = calculateDynamicReboundStats(daily, minDropToDetect = 3.0)
|
||||||
|
|
||||||
|
val guideBuilder = java.lang.StringBuilder()
|
||||||
|
var isVeryFavorable = false
|
||||||
|
|
||||||
|
// 2. 가장 신뢰도 높은 '주봉(Weekly)' 기준으로 수익률 보정 평가
|
||||||
|
if (weeklyStats.isValid) {
|
||||||
|
// 과거 평균 반등폭이 내 목표 수익률의 1.5배 이상이라면? -> "안전 마진 확보(유리함)"
|
||||||
|
if (weeklyStats.avgReboundAmplitude >= targetProfitRate * 1.5) {
|
||||||
|
isVeryFavorable = true
|
||||||
|
guideBuilder.append("🔥 [프리미엄 타점] 과거 평균 반등폭(${"%.1f".format(weeklyStats.avgReboundAmplitude)}%)이 목표수익률을 크게 상회합니다. 타점을 조금 더 관대하게 잡습니다.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유리한 조건이면 오차 허용 범위를 넓혀서 예측일에 조금 더 일찍 진입할 수 있게 보정
|
||||||
|
val adjustedTolerance = if (isVeryFavorable) weeklyStats.timeTolerance * 1.5 else weeklyStats.timeTolerance
|
||||||
|
|
||||||
|
guideBuilder.append("- 주간(W): 평균 ${"%.1f".format(weeklyStats.avgReboundPeriod)}주 조정 후 반등 (오차 ±${"%.1f".format(adjustedTolerance)}주)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 일봉 및 월봉 코멘트 추가
|
||||||
|
if (dailyStats.isValid) {
|
||||||
|
val dailyAdjTolerance = if (isVeryFavorable) dailyStats.timeTolerance * 1.5 else dailyStats.timeTolerance
|
||||||
|
guideBuilder.append("- 일간(D): 단기 평균 ${"%.1f".format(dailyStats.avgReboundPeriod)}일 조정 후 반등 (오차 ±${"%.1f".format(dailyAdjTolerance)}일)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthlyStats.isValid) {
|
||||||
|
guideBuilder.append("- 월간(M): 장기 사이클 평균 ${"%.1f".format(monthlyStats.avgReboundPeriod)}개월\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guideBuilder.isEmpty()) {
|
||||||
|
return "명확한 MTF(다중 타임프레임) 반등 패턴이 없습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
return guideBuilder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규] 종목이 과열되지 않고 안정적으로 우상향 추세를 타고 있는지 확인합니다.
|
||||||
|
*/
|
||||||
|
fun checkSteadyUptrend(candles: List<CandleData>): Boolean {
|
||||||
|
if (candles.size < 20) return false
|
||||||
|
|
||||||
|
val currentPrice = candles.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
|
// 1. 이동평균선 계산 (5일, 20일)
|
||||||
|
val ma5 = candles.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||||
|
val ma20 = candles.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||||
|
|
||||||
|
// 5일 전의 20일 이평선 (20일선 자체가 위로 고개를 들고 있는지 확인)
|
||||||
|
val pastMa20 = candles.dropLast(5).takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||||
|
|
||||||
|
// 2. 정배열 및 추세 확인 (현재가 > 5일선 > 20일선)
|
||||||
|
val isTrendAligned = currentPrice > ma5 && ma5 > ma20
|
||||||
|
val isMa20Rising = ma20 > pastMa20
|
||||||
|
|
||||||
|
// 3. 이격도 과열 방지 (20일선 대비 너무 높게 떠 있으면 추격 매수 금지)
|
||||||
|
// 기존에 만드신 isOverheatedStock()을 재활용하거나, 여기서 타이트하게 110% 등으로 제어합니다.
|
||||||
|
val disparity20 = (currentPrice / ma20) * 100
|
||||||
|
val isNotTooHigh = disparity20 <= 110.0 // 20일선 대비 10% 이내에 있을 때만 안전한 눌림/우상향으로 인정
|
||||||
|
|
||||||
|
// 🌟 정배열이고, 20일선이 상승 중이며, 너무 과열되지 않았을 때만 True
|
||||||
|
return isTrendAligned && isMa20Rising && isNotTooHigh && !isOverheatedStock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 캔들의 등락률(변동성)을 기반으로 통계적인 다음 캔들의 가격 이동 범위를 예측합니다.
|
||||||
|
*/
|
||||||
|
fun calculateVolatilityForecast(candles: List<CandleData>, period: Int = 20): VolatilityForecast {
|
||||||
|
if (candles.size < period + 1) {
|
||||||
|
// 데이터가 부족하면 현재가 그대로 반환
|
||||||
|
val cp = candles.lastOrNull()?.stck_prpr?.toDouble() ?: 0.0
|
||||||
|
return VolatilityForecast(cp, cp, cp, cp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentPrice = candles.last().stck_prpr.toDouble()
|
||||||
|
val dailyReturns = mutableListOf<Double>()
|
||||||
|
|
||||||
|
// 1. 최근 N일간의 등락률(%) 추출
|
||||||
|
val subList = candles.takeLast(period + 1)
|
||||||
|
for (i in 1 until subList.size) {
|
||||||
|
val prevClose = subList[i-1].stck_prpr.toDouble()
|
||||||
|
val currClose = subList[i].stck_prpr.toDouble()
|
||||||
|
if (prevClose > 0) {
|
||||||
|
dailyReturns.add((currClose - prevClose) / prevClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 등락률의 평균(Mean)과 표준편차(Volatility) 산출
|
||||||
|
val meanReturn = dailyReturns.average()
|
||||||
|
val variance = dailyReturns.map { Math.pow(it - meanReturn, 2.0) }.average()
|
||||||
|
val stdDev = Math.sqrt(variance)
|
||||||
|
|
||||||
|
// 3. 현재가에 통계적 변동성(Z-Score)을 곱하여 미래 가격 범위 예측
|
||||||
|
val realisticHigh = currentPrice * (1 + meanReturn + stdDev)
|
||||||
|
val realisticLow = currentPrice * (1 + meanReturn - stdDev)
|
||||||
|
|
||||||
|
val extremeHigh = currentPrice * (1 + meanReturn + (stdDev * 2))
|
||||||
|
val extremeLow = currentPrice * (1 + meanReturn - (stdDev * 2))
|
||||||
|
|
||||||
|
return VolatilityForecast(realisticHigh, realisticLow, extremeHigh, extremeLow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(Bottom)을 계산합니다.
|
||||||
|
* ATR을 추가로 받아 변동성 기반의 바닥 밴드를 형성합니다.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* [개선] 노이즈(윗꼬리/아랫꼬리)를 제거한 현실적인 평균 고점/저점을 기준으로 하락률을 계산합니다.
|
||||||
|
*/
|
||||||
|
fun predictDropBottom(
|
||||||
|
candles: List<CandleData>,
|
||||||
|
reboundStats: ReboundStats,
|
||||||
|
volatility: VolatilityForecast,
|
||||||
|
currentAtr: Double
|
||||||
|
): DropPrediction? {
|
||||||
|
if (candles.size < 20 || !reboundStats.isValid) return null
|
||||||
|
|
||||||
|
// 전체 캔들의 80% 구간만 사용 (너무 오래된 데이터 제외)
|
||||||
|
val recentCandles = candles.takeLast((candles.size.times(0.8).toInt()))
|
||||||
|
|
||||||
|
// 1. 고가 평균점 (Smoothed Peak) 만들기
|
||||||
|
// 최고가들을 내림차순 정렬하여 상위 3개의 평균을 구함 (비정상적인 윗꼬리 1~2개 무시 효과)
|
||||||
|
val topHighs = recentCandles.map { it.stck_hgpr.toDouble() }.sortedDescending()
|
||||||
|
val smoothedPeak = if (topHighs.size >= 3) {
|
||||||
|
topHighs.take(3).average()
|
||||||
|
} else {
|
||||||
|
topHighs.firstOrNull() ?: 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smoothedPeak == 0.0) return null
|
||||||
|
|
||||||
|
// 2. 저점 평균점 (Smoothed Bottom) 만들기
|
||||||
|
// 최저가들을 오름차순 정렬하여 하위 3개의 평균을 구함 (순간적인 투매 아랫꼬리 방어)
|
||||||
|
val bottomLows = recentCandles.map { it.stck_lwpr.toDouble() }.sorted()
|
||||||
|
val smoothedBottom = if (bottomLows.size >= 3) {
|
||||||
|
bottomLows.take(3).average()
|
||||||
|
} else {
|
||||||
|
bottomLows.firstOrNull() ?: 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentPrice = candles.last().stck_prpr.toDouble()
|
||||||
|
|
||||||
|
// 3. 말씀하신 '고가는 좀 낮게, 저가는 저점에 가깝게' 보정
|
||||||
|
// 평균 고점에서 변동성(ATR)의 일정 비율만큼 한 번 더 깎아내서 더 보수적인 진짜 고점(True Peak)을 만듦
|
||||||
|
val truePeak = smoothedPeak - (currentAtr * 0.3)
|
||||||
|
|
||||||
|
// 현재가 대비 하락률은 보정된 truePeak를 기준으로 계산
|
||||||
|
val currentDropRate = (truePeak - currentPrice) / truePeak * 100.0
|
||||||
|
|
||||||
|
// 예상 바닥가도 truePeak 기준에서 과거 평균 하락폭을 빼서 산출
|
||||||
|
val expectedBottomPrice = truePeak * (1.0 - (reboundStats.avgDropRate / 100.0))
|
||||||
|
val remainingDropRate = reboundStats.avgDropRate - currentDropRate
|
||||||
|
|
||||||
|
// 바닥권 인정 마진 (ATR 기반)
|
||||||
|
//B. 바닥권 판정 마진predictDropBottom currentAtr * 0.7 여유 마진 마진을 축소(currentAtr * 0.3)하여 예상 바닥에 더 근접해야 인정
|
||||||
|
val bottomMargin = currentAtr * 0.6
|
||||||
|
|
||||||
|
// 저점 평균(smoothedBottom)도 마지노선 계산에 추가 가중치로 섞어줌으로써 저점에 더 밀착시킴
|
||||||
|
val adjustedExtremeLow = (volatility.extremeLow + smoothedBottom) / 2.0
|
||||||
|
|
||||||
|
val isBottomZone = currentPrice <= (expectedBottomPrice + bottomMargin) || currentPrice <= (adjustedExtremeLow + bottomMargin)
|
||||||
|
|
||||||
|
return DropPrediction(
|
||||||
|
recentPeakPrice = truePeak, // 외부에는 보정된 고점을 전달
|
||||||
|
expectedBottomPrice = expectedBottomPrice,
|
||||||
|
extremeSupportPrice = adjustedExtremeLow, // 보정된 지지선 전달
|
||||||
|
currentDropRate = -currentDropRate,
|
||||||
|
remainingDropRate = -remainingDropRate,
|
||||||
|
isBottomZone = isBottomZone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🌟 [신규] 바닥권 도달 시, 하락이 멈추고 지지선이 형성되었는지(Brake) 확인합니다.
|
||||||
|
*/
|
||||||
|
fun checkBrakeAndReversal(candles: List<CandleData>): Boolean {
|
||||||
|
if (candles.size < 3) return false
|
||||||
|
|
||||||
|
val today = candles.last()
|
||||||
|
val yesterday = candles[candles.size - 2]
|
||||||
|
|
||||||
|
val tClose = today.stck_prpr.toDouble()
|
||||||
|
val tOpen = today.stck_oprc.toDouble()
|
||||||
|
val tHigh = today.stck_hgpr.toDouble()
|
||||||
|
val tLow = today.stck_lwpr.toDouble()
|
||||||
|
val tVol = today.cntg_vol.toDouble()
|
||||||
|
|
||||||
|
val yVol = yesterday.cntg_vol.toDouble()
|
||||||
|
|
||||||
|
// 1. 밑꼬리 확인 (망치형 / 도지형)
|
||||||
|
// 몸통(Body) 대비 아래쪽 꼬리(Lower Shadow)가 얼마나 긴가?
|
||||||
|
val body = abs(tClose - tOpen)
|
||||||
|
val lowerShadow = minOf(tClose, tOpen) - tLow
|
||||||
|
val upperShadow = tHigh - maxOf(tClose, tOpen)
|
||||||
|
|
||||||
|
// 꼬리가 몸통보다 1.5배 이상 길고, 윗꼬리보다 아랫꼬리가 더 길면 강력한 누군가의 '매수 개입(지지)'으로 봅니다.
|
||||||
|
val hasLongLowerShadow = (lowerShadow > body * 1.5) && (lowerShadow > upperShadow)
|
||||||
|
|
||||||
|
// 2. 단기 양봉 전환 (하락을 멈추고 고개를 듦)
|
||||||
|
val isBullishBrake = tClose > tOpen && tClose >= yesterday.stck_prpr.toDouble()
|
||||||
|
|
||||||
|
// 3. 투매 진정 (거래량 급감)
|
||||||
|
// 전일 대비 거래량이 눈에 띄게 줄었다는 것은 매도세(던지는 물량)가 말랐다는 뜻입니다.
|
||||||
|
val isVolumeDriedUp = tVol < yVol * 0.7
|
||||||
|
|
||||||
|
// 🌟 지지(밑꼬리)가 나왔거나, 양봉으로 돌렸거나, 던지는 물량이 마른 상태 중 하나라도 충족해야 브레이크가 걸린 것으로 봅니다.
|
||||||
|
return hasLongLowerShadow || isBullishBrake || isVolumeDriedUp
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DropPrediction(
|
||||||
|
val recentPeakPrice: Double, // 최근 단기 고점
|
||||||
|
val expectedBottomPrice: Double, // 과거 평균 하락률(avgDropRate)을 적용한 1차 예상 바닥가
|
||||||
|
val extremeSupportPrice: Double, // 2표준편차(extremeLow) 기반의 통계적 2차 마지노선
|
||||||
|
val currentDropRate: Double, // 단기 고점 대비 현재까지 하락한 비율 (%)
|
||||||
|
val remainingDropRate: Double, // 1차 예상 바닥까지 남은 추가 하락 여력 (%) - 양수면 더 빠질 공간이 있다는 뜻
|
||||||
|
val isBottomZone: Boolean // 현재 가격이 바닥권(예상 바닥가의 상하 2% 이내)에 진입했는지 여부
|
||||||
|
)
|
||||||
|
|
||||||
|
data class VolatilityForecast(
|
||||||
|
val realisticHigh: Double, // 1표준편차 상단 (현실적 목표가, 68% 확률 내)
|
||||||
|
val realisticLow: Double, // 1표준편차 하단 (현실적 지지선)
|
||||||
|
val extremeHigh: Double, // 2표준편차 상단 (오버슈팅 저항선, 95% 확률 내)
|
||||||
|
val extremeLow: Double // 2표준편차 하단 (투매 마지노선)
|
||||||
|
)
|
||||||
|
data class ReboundStats(
|
||||||
|
val avgReboundPeriod: Double = 0.0, // 평균 반등 소요 캔들 (일/주/월)
|
||||||
|
val timeTolerance: Double = 2.0, // 오차 허용 범위 (표준편차)
|
||||||
|
val avgDropRate: Double = 5.0, // 평균 하락폭
|
||||||
|
val avgReboundAmplitude: Double = 0.0, // 🌟 [신규] 바닥 찍고 평균적으로 몇 % 올랐는가?
|
||||||
|
val isValid: Boolean = false
|
||||||
|
)
|
||||||
@@ -242,7 +242,7 @@ object DatabaseFactory {
|
|||||||
fun findAllMonitoringTrades(): List<AutoTradeItem> {
|
fun findAllMonitoringTrades(): List<AutoTradeItem> {
|
||||||
return transaction(mainDb) {
|
return transaction(mainDb) {
|
||||||
AutoTradeTable.select {
|
AutoTradeTable.select {
|
||||||
AutoTradeTable.status neq "COMPLETED"
|
AutoTradeTable.status notInList listOf(TradeStatus.COMPLETED, TradeStatus.EXPIRED)
|
||||||
}.map { mapToAutoTradeItem(it) }
|
}.map { mapToAutoTradeItem(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,6 +614,22 @@ object TradingLogStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addNotice(name : String, code : String, log: String) {
|
fun addNotice(name : String, code : String, log: String) {
|
||||||
|
var isSendable = false
|
||||||
|
val current = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (KisSession.tradeConfig.useTagsShare.contains("NOTICE") &&
|
||||||
|
KisSession.tradeConfig.useLogKeywordsShare.any { log.contains(it) }) {
|
||||||
|
|
||||||
|
// 대소문자 구분 없이 key를 찾기 위해 원본 code를 가공하거나 그대로 사용
|
||||||
|
val lastSentTime = noticeFilter[code.uppercase()]
|
||||||
|
|
||||||
|
// 기록이 없거나(처음 보냄), 마지막 발송 기준 30분이 지났다면
|
||||||
|
if (lastSentTime == null || (current - lastSentTime > (1000 * 60) * KisSession.tradeConfig.noticeGapTime)) {
|
||||||
|
isSendable = true
|
||||||
|
noticeFilter[code.uppercase()] = current // 즉시 발송 시간 갱신하여 중복 방지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
|
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
|
||||||
decisionLogs.add(
|
decisionLogs.add(
|
||||||
@@ -623,28 +639,24 @@ object TradingLogStore {
|
|||||||
decision = "NOTICE",
|
decision = "NOTICE",
|
||||||
confidence = 100.0,
|
confidence = 100.0,
|
||||||
reason = log
|
reason = log
|
||||||
).apply {
|
|
||||||
if (KisSession.tradeConfig.useTagsShare.contains(decision) && KisSession.tradeConfig.useLogKeywordsShare.any {
|
|
||||||
log.contains(
|
|
||||||
it
|
|
||||||
)
|
)
|
||||||
}) {
|
|
||||||
var current = System.currentTimeMillis()
|
|
||||||
var sendable = noticeFilter.filter { it.key.equals(code, true) && ((current - it.value) > 1000 * 60 * 30L)}.isNotEmpty()
|
|
||||||
if (sendable) {
|
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
NewsService.sendTelegramMessage("${this@apply.decision}$name[$code] ${log}")
|
|
||||||
|
|
||||||
}}
|
|
||||||
noticeFilter[code] = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
if (isSendable) {
|
||||||
|
applicationScope.launch {
|
||||||
|
try {
|
||||||
|
NewsService.sendTelegramMessage("NOTICE $name[$code] $log")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 발송 실패 시 원상복구를 원한다면 주석 해제 (단, 일시적 네트웍 장애 시 도배 위험 있음)
|
||||||
|
// noticeFilter.remove(code.uppercase())
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val applicationScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
var noticeFilter = hashMapOf<String, Long>()
|
var noticeFilter = hashMapOf<String, Long>()
|
||||||
fun addNotice(name : String, code : String, log: String, qty: Int? = null) {
|
fun addNotice(name : String, code : String, log: String, qty: Int? = null) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
|
|||||||
@@ -254,6 +254,20 @@ class TradeConfig {
|
|||||||
var plusFilter : Double = 15.0
|
var plusFilter : Double = 15.0
|
||||||
var excuteCountOnMin : Int = 2
|
var excuteCountOnMin : Int = 2
|
||||||
var autoSellOrder : Boolean = false
|
var autoSellOrder : Boolean = false
|
||||||
|
var excuteMinCheck : Int = 2
|
||||||
|
var noticeGapTime : Int = 60
|
||||||
|
var lowerAveragePrice : Boolean = true
|
||||||
|
var lowerAverageStockCount : Int = 1
|
||||||
|
var lowerAverageMaxRate : Double = 15.0
|
||||||
|
var lowerAverageMinRate : Double = 25.0
|
||||||
|
var lowerAverageTargetCount : Int = 2
|
||||||
|
var autoSellOrderMin : Double = -15.0
|
||||||
|
var autoSellOrderMax : Double = -29.0
|
||||||
|
var autoSellOrderAppend : Int = 3
|
||||||
|
var minExpectedProfitRate: Double = 2.0 // 필터링 기준 최소 기대 수익률 (%)
|
||||||
|
var maxExpectedReboundDays: Double = 10.0 // 필터링 기준 최대 허용 반등 주기 (일)
|
||||||
|
var minExpectedReboundDays: Double = 1.5
|
||||||
|
var isUpcomingDividend : Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -346,4 +360,13 @@ object KisSession {
|
|||||||
fun isAvailBuyTime(now: LocalTime) : Boolean {
|
fun isAvailBuyTime(now: LocalTime) : Boolean {
|
||||||
return now.isBefore(endBuyTime()) && now.isAfter(startBuyTime())
|
return now.isBefore(endBuyTime()) && now.isAfter(startBuyTime())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isMarketOpenTime(now: LocalTime) : Boolean {
|
||||||
|
return now.isBefore(LocalTime.of(15,30)) && now.isAfter(LocalTime.of(9,0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMarketAnalyzerTime(now: LocalTime) : Boolean {
|
||||||
|
return now.isBefore(LocalTime.of(19,0)) && now.isAfter(LocalTime.of(8,0))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,21 @@ cntg_vol : $cntg_vol
|
|||||||
acml_tr_pbmn : $acml_tr_pbmn
|
acml_tr_pbmn : $acml_tr_pbmn
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시가 대비 현재가 변동률(%)을 계산하여 반환합니다.
|
||||||
|
*/
|
||||||
|
fun getFluctuationRate(): Double {
|
||||||
|
val openPrice = stck_oprc.toDoubleOrNull() ?: 0.0
|
||||||
|
val currentPrice = stck_prpr.toDoubleOrNull() ?: 0.0
|
||||||
|
|
||||||
|
// 시가가 0이거나 데이터가 없는 경우 0.0 반환 (0으로 나누기 방지)
|
||||||
|
if (openPrice == 0.0) return 0.0
|
||||||
|
|
||||||
|
// 변동률 계산 공식: ((현재가 - 시가) / 시가) * 100
|
||||||
|
return ((currentPrice - openPrice) / openPrice) * 100
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@Serializable
|
@Serializable
|
||||||
data class OverseasCandleData(
|
data class OverseasCandleData(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package model
|
|||||||
import AutoTradeItem
|
import AutoTradeItem
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class StockBalanceResponse(
|
data class StockBalanceResponse(
|
||||||
val rt_cd: String = "",
|
val rt_cd: String = "",
|
||||||
@@ -106,6 +108,14 @@ enum class RankingType(
|
|||||||
HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", emptyMap())
|
HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", emptyMap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class UpcomingDividend(
|
||||||
|
val hasDividend: Boolean,
|
||||||
|
val stockCode: String,
|
||||||
|
val stockName: String,
|
||||||
|
val exDividendDate: String?,
|
||||||
|
val dividendAmount: BigDecimal?
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RankingStock(
|
data class RankingStock(
|
||||||
val hts_kor_isnm: String = "", // 종목명
|
val hts_kor_isnm: String = "", // 종목명
|
||||||
@@ -118,9 +128,9 @@ data class RankingStock(
|
|||||||
val mrkt_div_cls_code : String = "J",
|
val mrkt_div_cls_code : String = "J",
|
||||||
) {
|
) {
|
||||||
val name : String
|
val name : String
|
||||||
get() = listOf(hts_kor_isnm , hts_kor_alph_nm , mkrtc_objt_iscd).firstOrNull { it.isNotBlank() } ?: ""
|
get() = listOf(hts_kor_isnm , hts_kor_alph_nm).firstOrNull { it.isNotBlank() } ?: ""
|
||||||
val code : String
|
val code : String
|
||||||
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd , hts_kor_isnm).firstOrNull { it.isNotBlank() } ?: ""
|
get() = listOf(mksc_shrn_iscd , mkrtc_objt_iscd , stck_shrn_iscd).firstOrNull { it.isNotBlank() } ?: ""
|
||||||
}
|
}
|
||||||
@Serializable
|
@Serializable
|
||||||
data class OverseasRankingResponse(
|
data class OverseasRankingResponse(
|
||||||
@@ -163,7 +173,7 @@ data class UnifiedStockHolding(
|
|||||||
val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt)
|
val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt)
|
||||||
val pchsAmount: String = "0" // 총 매입금액 (pchs_amt)
|
val pchsAmount: String = "0" // 총 매입금액 (pchs_amt)
|
||||||
) {
|
) {
|
||||||
val isTodayEntry: Boolean get() = thdtBuyQty.toIntOrNull() ?: 0 > 0
|
val isTodayEntry: Boolean get() = (thdtBuyQty.toIntOrNull() ?: 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -237,6 +247,7 @@ data class ExecutionData(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class CurrentPriceResponse(
|
data class CurrentPriceResponse(
|
||||||
val rt_cd: String, // 0: 성공, 0 이외: 실패
|
val rt_cd: String, // 0: 성공, 0 이외: 실패
|
||||||
val msg_cd: String,
|
val msg_cd: String,
|
||||||
@@ -244,19 +255,36 @@ data class CurrentPriceResponse(
|
|||||||
val output: CurrentPriceOutput
|
val output: CurrentPriceOutput
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class CurrentPriceOutput(
|
data class CurrentPriceOutput(
|
||||||
val stck_prpr: String, // 주식 현재가
|
// --- 📊 기존 시세 및 기본 펀더멘털 필드 ---
|
||||||
val prdy_vrss: String, // 전일 대비
|
val stck_shrn_iscd: String = "", // 종목 코드
|
||||||
val prdy_ctrt: String, // 전일 대비율
|
val stck_prpr: String = "0", // 주식 현재가
|
||||||
val acml_vol: String, // 누적 거래량
|
val prdy_vrss: String = "0", // 전일 대비
|
||||||
val stck_oprc: String, // 시가
|
val prdy_ctrt: String = "0.0", // 전일 대비율
|
||||||
val stck_hgpr: String, // 고가
|
val acml_vol: String = "0", // 누적 거래량
|
||||||
val stck_lwpr: String, // 저가
|
val stck_oprc: String = "0", // 시가
|
||||||
val hts_avls: String, // 시가총액
|
val stck_hgpr: String = "0", // 고가
|
||||||
val per: String,
|
val stck_lwpr: String = "0", // 저가
|
||||||
val pbr: String,
|
val hts_avls: String = "0", // 시가총액
|
||||||
val stck_shrn_iscd: String // 종목 코드
|
val per: String = "0.0",
|
||||||
// ... 필요한 필드가 있다면 Python 모델을 참고하여 추가하세요.
|
val pbr: String = "0.0",
|
||||||
|
|
||||||
|
// --- 🚨 신규 추가: 리스크 필터링 및 상태 필드 ---
|
||||||
|
val rprs_mrkt_kor_name: String = "", // 대표 시장 한글 명 (KOSPI, KOSDAQ 등)
|
||||||
|
val iscd_stat_cls_code: String = "", // 종목 상태 구분 코드 (51:관리, 52:위험, 58:정지 등)
|
||||||
|
val mrkt_warn_cls_code: String = "", // 시장경고코드 (보통 "00"이 정상)
|
||||||
|
val short_over_yn: String = "", // 단기과열여부 (Y/N)
|
||||||
|
val sltr_yn: String = "", // 정리매매여부 (Y/N)
|
||||||
|
val mang_issu_cls_code: String = "", // 관리종목여부 (1/0 또는 Y/N)
|
||||||
|
val temp_stop_yn: String = "", // 임시 정지 여부 (Y/N)
|
||||||
|
val invt_caful_yn: String = "" // 투자유의여부 (Y/N)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 필터링 결과를 담을 객체
|
||||||
|
data class RiskValidationResult(
|
||||||
|
val isSafe: Boolean,
|
||||||
|
val rejectReason: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ class TradingDecision {
|
|||||||
var financialData : String? = null
|
var financialData : String? = null
|
||||||
var analyzer : TechnicalAnalyzer? = null
|
var analyzer : TechnicalAnalyzer? = null
|
||||||
var signalModel : ScalpingSignalModel? = null
|
var signalModel : ScalpingSignalModel? = null
|
||||||
|
var maxRealisticProfitRate :Double = 0.0
|
||||||
|
var reboundDaysDaily: Double = 0.0 // 일봉 기준 평균 반등 소요일
|
||||||
|
var isWatering: Boolean = false
|
||||||
|
var reboundWeeksWeekly: Double = 0.0 // 주봉 기준 평균 반등 소요주
|
||||||
|
var isReboundApproaching: Boolean = false // 반등 주기에 근접했는지 여부
|
||||||
|
var reboundGuideMessage: String = "반등 주기 데이터 없음" // UI나 로그에 노출할 가이드 메시지
|
||||||
|
|
||||||
fun shortPossible() =
|
fun shortPossible() =
|
||||||
listOf<Double>(ultraShortScore,
|
listOf<Double>(ultraShortScore,
|
||||||
@@ -60,7 +66,8 @@ class TradingDecision {
|
|||||||
longTermScore).average()
|
longTermScore).average()
|
||||||
|
|
||||||
|
|
||||||
fun summary() : String{
|
fun summary(
|
||||||
|
targetProfitRate: Double) : String{
|
||||||
return """
|
return """
|
||||||
$corpName[$stockName]
|
$corpName[$stockName]
|
||||||
수익실현 가능성 : ${profitPossible()}
|
수익실현 가능성 : ${profitPossible()}
|
||||||
@@ -75,6 +82,8 @@ financialScore: $financialScore
|
|||||||
newsScore: $newsScore
|
newsScore: $newsScore
|
||||||
decision: $decision
|
decision: $decision
|
||||||
reason: $reason
|
reason: $reason
|
||||||
|
예측가능 수익율 : ${maxRealisticProfitRate}
|
||||||
|
반등 주기 가이드: ${analyzer?.generateMTFReboundGuide(targetProfitRate)}
|
||||||
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
@@ -93,6 +102,7 @@ reason: $reason
|
|||||||
confidence: $confidence
|
confidence: $confidence
|
||||||
기술 분석: $techSummary
|
기술 분석: $techSummary
|
||||||
뉴스 점수: $newsScore
|
뉴스 점수: $newsScore
|
||||||
|
반등 주기 가이드: $reboundGuideMessage
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
import model.*
|
import model.*
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ object KisTradeService {
|
|||||||
val body = response.body<JsonObject>()
|
val body = response.body<JsonObject>()
|
||||||
// output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일
|
// output의 opnd_yn (영업일 여부)가 'Y'이면 영업일, 'N'이면 휴장일
|
||||||
val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y"
|
val isOpeningDay = body["output"]?.jsonArray?.firstOrNull()?.jsonObject?.get("opnd_yn")?.jsonPrimitive?.content == "Y"
|
||||||
Result.success(!isOpeningDay)
|
Result.success(isOpeningDay)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
@@ -122,6 +123,7 @@ 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),
|
||||||
@@ -130,7 +132,9 @@ object KisTradeService {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -224,6 +228,78 @@ object KisTradeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun fetchUpcomingDividend(
|
||||||
|
stockCode: String
|
||||||
|
): Result<UpcomingDividend> {
|
||||||
|
|
||||||
|
val config = KisSession.config
|
||||||
|
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||||
|
|
||||||
|
val fromDate = LocalDate.now().format(formatter)
|
||||||
|
val toDate = LocalDate.now().plusMonths(6).format(formatter)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val response = client.get("$prodUrl/uapi/domestic-stock/v1/ksdinfo/dividend") {
|
||||||
|
header("authorization", "Bearer ${config.marketToken}")
|
||||||
|
header("appkey", config.realAppKey)
|
||||||
|
header("appsecret", config.realSecretKey)
|
||||||
|
header("tr_id", "HHKDB669102C0")
|
||||||
|
header("custtype", "P")
|
||||||
|
|
||||||
|
// 실제 API 문서의 파라미터명으로 변경
|
||||||
|
parameter("CTS", "")
|
||||||
|
parameter("GB1", "0")
|
||||||
|
parameter("SHT_CD", stockCode)
|
||||||
|
parameter("HIGH_GB", "")
|
||||||
|
parameter("F_DT", fromDate)
|
||||||
|
parameter("T_DT", toDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body<JsonObject>()
|
||||||
|
|
||||||
|
if (body["rt_cd"]?.jsonPrimitive?.content != "0") {
|
||||||
|
return Result.failure(
|
||||||
|
Exception(body["msg1"]?.jsonPrimitive?.content ?: "배당 조회 실패")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val output = body["output1"]?.jsonArray.orEmpty()
|
||||||
|
|
||||||
|
val item = output
|
||||||
|
.map { it.jsonObject }
|
||||||
|
.firstOrNull {
|
||||||
|
it["sht_cd"]?.jsonPrimitive?.content == stockCode
|
||||||
|
}
|
||||||
|
println("fetchUpcomingDividend 배당 body >>> ${item}")
|
||||||
|
if (item == null) {
|
||||||
|
return Result.success(
|
||||||
|
UpcomingDividend(
|
||||||
|
hasDividend = false,
|
||||||
|
stockCode = stockCode,
|
||||||
|
stockName = "",
|
||||||
|
exDividendDate = null,
|
||||||
|
dividendAmount = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
UpcomingDividend(
|
||||||
|
hasDividend = true,
|
||||||
|
stockCode = item["sht_cd"]?.jsonPrimitive?.content.orEmpty(),
|
||||||
|
stockName = item["isin_name"]?.jsonPrimitive?.content.orEmpty(),
|
||||||
|
exDividendDate = item["record_date"]?.jsonPrimitive?.content,
|
||||||
|
dividendAmount = item["per_sto_divi_amt"]?.jsonPrimitive?.content?.toBigDecimalOrNull()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [추가] 기간별(일/주/월) 차트 데이터 조회
|
* [추가] 기간별(일/주/월) 차트 데이터 조회
|
||||||
* @param periodCode "D"(일), "W"(주), "M"(월)
|
* @param periodCode "D"(일), "W"(주), "M"(월)
|
||||||
@@ -234,8 +310,8 @@ object KisTradeService {
|
|||||||
isDomestic: Boolean = true
|
isDomestic: Boolean = true
|
||||||
): Result<List<CandleData>> {
|
): Result<List<CandleData>> {
|
||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
val path = if (isDomestic) "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
val path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||||
else "/uapi/overseas-stock/v1/quotations/inquire-daily-itemchartprice"
|
|
||||||
|
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||||
@@ -244,7 +320,7 @@ object KisTradeService {
|
|||||||
// [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전)
|
// [수정] 100개를 가져오기 위해 시작일을 너무 멀지 않게 설정 (약 6개월 전)
|
||||||
// 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다.
|
// 이렇게 하면 종료일(오늘)부터 소급하여 최대 100개의 최신 데이터를 안전하게 가져옵니다.
|
||||||
val startDate = when (periodCode) {
|
val startDate = when (periodCode) {
|
||||||
"D" -> today.minusMonths(6).format(formatter) // 일봉: 6개월치면 100개 충분
|
"D" -> today.minusDays(90).format(formatter) // 일봉: 6개월치면 100개 충분
|
||||||
"W" -> today.minusYears(2).format(formatter) // 주봉: 2년치
|
"W" -> today.minusYears(2).format(formatter) // 주봉: 2년치
|
||||||
"M" -> today.minusYears(8).format(formatter) // 월봉: 8년치
|
"M" -> today.minusYears(8).format(formatter) // 월봉: 8년치
|
||||||
else -> today.minusYears(1).format(formatter)
|
else -> today.minusYears(1).format(formatter)
|
||||||
@@ -259,7 +335,7 @@ object KisTradeService {
|
|||||||
|
|
||||||
parameter("FID_INPUT_DATE_1", startDate)
|
parameter("FID_INPUT_DATE_1", startDate)
|
||||||
parameter("FID_INPUT_DATE_2", endDate)
|
parameter("FID_INPUT_DATE_2", endDate)
|
||||||
parameter("FID_COND_MRKT_DIV_CODE", "J")
|
parameter("FID_COND_MRKT_DIV_CODE", if(LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) "UN" else "J")
|
||||||
parameter("FID_INPUT_ISCD", stockCode)
|
parameter("FID_INPUT_ISCD", stockCode)
|
||||||
parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M
|
parameter("FID_PERIOD_DIV_CODE", periodCode) // D, W, M
|
||||||
parameter("FID_ORG_ADJ_PRC", "0")
|
parameter("FID_ORG_ADJ_PRC", "0")
|
||||||
@@ -351,12 +427,14 @@ object KisTradeService {
|
|||||||
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
|
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
|
||||||
else -> if (isBuy) "TTTS3002U" else "TTTS3001U"
|
else -> if (isBuy) "TTTS3002U" else "TTTS3001U"
|
||||||
}
|
}
|
||||||
val finalOrderDivision = when {
|
var finalOrderDivision = when {
|
||||||
orderDivision.isNotEmpty() -> orderDivision
|
orderDivision.isNotEmpty() -> orderDivision
|
||||||
marketCode.equals("SOR") || price == "0" || price.isEmpty() -> "01" // 시장가
|
marketCode.equals("SOR") || price == "0" || price.isEmpty() -> "01" // 시장가
|
||||||
else -> "00" // 지정가
|
else -> "00" // 지정가
|
||||||
}
|
}
|
||||||
|
if (marketCode.equals("KRX") && LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) {
|
||||||
|
finalOrderDivision = "41"
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
|
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
|
||||||
@@ -488,21 +566,27 @@ object KisTradeService {
|
|||||||
header("Content-Type", "application/json; charset=utf-8")
|
header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
// 파라미터 설정
|
// 파라미터 설정
|
||||||
parameter("FID_COND_MRKT_DIV_CODE", "J") // J: 주식
|
// parameter("FID_COND_MRKT_DIV_CODE", "J") // J: 주식
|
||||||
|
parameter("FID_COND_MRKT_DIV_CODE", if(LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) "UN" else "J")
|
||||||
parameter("FID_INPUT_ISCD", stockCode) // 종목코드
|
parameter("FID_INPUT_ISCD", stockCode) // 종목코드
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
val body = response.body<CurrentPriceResponse>()
|
val body = response.body<CurrentPriceResponse>()
|
||||||
if (body.rt_cd == "0") {
|
if (body.rt_cd == "0") {
|
||||||
|
// println("${body.output}")
|
||||||
Result.success(body.output)
|
Result.success(body.output)
|
||||||
} else {
|
} else {
|
||||||
|
println("API 에러: ${body.msg1}")
|
||||||
Result.failure(Exception("API 에러: ${body.msg1}"))
|
Result.failure(Exception("API 에러: ${body.msg1}"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
println("HTTP 에러: ${response.status}")
|
||||||
Result.failure(Exception("HTTP 에러: ${response.status}"))
|
Result.failure(Exception("HTTP 에러: ${response.status}"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
println("HTTP 에러: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,11 +688,11 @@ object KisTradeService {
|
|||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가]
|
if (!coroutineContext.isActive) throw _root_ide_package_.io.ktor.utils.io.CancellationException("UI에서 작업을 취소함") // [추가]
|
||||||
println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)")
|
// println("📡 [Step $pageCount] 요청 전송 중... (tr_cont: $trCont)")
|
||||||
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
|
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
|
||||||
header("authorization", "Bearer ${config.tradeToken}")
|
header("authorization", "Bearer ${config.tradeToken}")
|
||||||
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
|
header("appkey", config.realAppKey)
|
||||||
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
|
header("appsecret", config.realSecretKey)
|
||||||
header("tr_id", trId)
|
header("tr_id", trId)
|
||||||
header("tr_cont", trCont)
|
header("tr_cont", trCont)
|
||||||
|
|
||||||
@@ -627,10 +711,14 @@ object KisTradeService {
|
|||||||
|
|
||||||
if (!response.status.isSuccess()) {
|
if (!response.status.isSuccess()) {
|
||||||
println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}")
|
println("❌ [Step $pageCount] $markgetCode HTTP 에러 발생: ${response.status}")
|
||||||
return Result.failure(Exception("HTTP Error: ${response.status}"))
|
if (allHoldings.isNotEmpty() && totalBalance != null) {
|
||||||
|
return Result.success(totalBalance.copy(output1 = allHoldings))
|
||||||
|
}
|
||||||
|
return Result.failure(Exception("HTTP Error: ${response.status} ${response.body<String>()}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body<StockBalanceResponse>()
|
val body = response.body<StockBalanceResponse>()
|
||||||
|
// println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}\n${body.output2}\n\n")
|
||||||
println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
|
println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
|
||||||
|
|
||||||
allHoldings.addAll(body.output1)
|
allHoldings.addAll(body.output1)
|
||||||
@@ -641,13 +729,13 @@ object KisTradeService {
|
|||||||
ctxAreaFk = body.ctx_area_fk100 ?: ""
|
ctxAreaFk = body.ctx_area_fk100 ?: ""
|
||||||
ctxAreaNk = body.ctx_area_nk100 ?: ""
|
ctxAreaNk = body.ctx_area_nk100 ?: ""
|
||||||
|
|
||||||
println("📝 [Header Check] tr_cont: $trCont, ctx_area_nk100: $ctxAreaNk")
|
// println("📝 [Header Check] tr_cont: $trCont, ctx_area_nk100: $ctxAreaNk")
|
||||||
|
|
||||||
if ( trCont == "M") {
|
if ( trCont == "M") {
|
||||||
pageCount++
|
pageCount++
|
||||||
trCont = "N"
|
trCont = "N"
|
||||||
println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...")
|
println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...")
|
||||||
delay(250) // API 과부하 방지
|
delay(500) // API 과부하 방지
|
||||||
}
|
}
|
||||||
|
|
||||||
} while (trCont == "N")
|
} while (trCont == "N")
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ object KisWebSocketManager {
|
|||||||
// AES 복호화 실행
|
// AES 복호화 실행
|
||||||
val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv)
|
val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv)
|
||||||
val dataRows = decryptedData.split("^")
|
val dataRows = decryptedData.split("^")
|
||||||
println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}주 ${if(dataRows[13] == "01"){"체결"}else{"접수"} }")
|
println("🔔 복호화된 체결 통보: ${if (dataRows[4] == "01") {"매도"} else {"매수"}} ${dataRows[8]} ${dataRows[9]}주 ${if(dataRows[13] == "2") {"체결"} else {"접수"} }")
|
||||||
|
|
||||||
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
|
// UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부)
|
||||||
onExecutionReceived?.invoke(
|
onExecutionReceived?.invoke(
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ object RagService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
|
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, isWatering : Boolean, result: TradingDecisionCallback) {
|
||||||
val totalStartTime = System.currentTimeMillis()
|
val totalStartTime = System.currentTimeMillis()
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
@@ -246,11 +246,12 @@ object RagService {
|
|||||||
this.stockCode = stockCode
|
this.stockCode = stockCode
|
||||||
this.analyzer = technicalAnalyzer
|
this.analyzer = technicalAnalyzer
|
||||||
this.currentPrice = currentPrice
|
this.currentPrice = currentPrice
|
||||||
|
this.isWatering = isWatering
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSafetyBeltStockCodes.contains(stockCode)) {
|
if (isSafetyBeltStockCodes.contains(stockCode)) {
|
||||||
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
|
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
|
||||||
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
|
println("재무 안정성 부족 (캐시)")
|
||||||
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
|
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
|
||||||
return@coroutineScope
|
return@coroutineScope
|
||||||
}
|
}
|
||||||
@@ -277,9 +278,11 @@ object RagService {
|
|||||||
isSafetyBeltStockCodes.add(stockCode)
|
isSafetyBeltStockCodes.add(stockCode)
|
||||||
return@coroutineScope
|
return@coroutineScope
|
||||||
}
|
}
|
||||||
|
val techScore = tradingDecision.signalModel?.compositeScore ?: 0
|
||||||
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
|
// 🌟 신규 매수는 40점 컷오프, 물타기는 20점(또는 제한 없음)으로 하향
|
||||||
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
|
val minTechCutoff = if (tradingDecision.isWatering) 15 else 40
|
||||||
|
if (techScore < minTechCutoff) {
|
||||||
|
logTime(stockName, "기술 점수 미달 조기 종료 ${tradingDecision.signalModel?.compositeScore} , ${tradingDecision.signalModel?.successProbPct} ", techDuration, System.currentTimeMillis() - totalStartTime)
|
||||||
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) {
|
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) {
|
||||||
TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true)
|
TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true)
|
||||||
result(tradingDecision.apply {
|
result(tradingDecision.apply {
|
||||||
@@ -331,6 +334,7 @@ object RagService {
|
|||||||
result(finalDecision, true)
|
result(finalDecision, true)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
println("❌ [$stockName] 분석 실패: ${e.message}")
|
println("❌ [$stockName] 분석 실패: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +435,7 @@ object RagService {
|
|||||||
put("type", "json_object")
|
put("type", "json_object")
|
||||||
}
|
}
|
||||||
}.toString()
|
}.toString()
|
||||||
println("requestBodyJson =>> $requestBodyJson")
|
// println("requestBodyJson =>> $requestBodyJson")
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(LLM_API_URL())
|
.url(LLM_API_URL())
|
||||||
.post(requestBodyJson.toRequestBody(jsonMediaType))
|
.post(requestBodyJson.toRequestBody(jsonMediaType))
|
||||||
@@ -486,9 +490,27 @@ object RagService {
|
|||||||
val synthStartTime = System.currentTimeMillis()
|
val synthStartTime = System.currentTimeMillis()
|
||||||
val sysScore100 = calculateSystemPoint(scores) * 4.0
|
val sysScore100 = calculateSystemPoint(scores) * 4.0
|
||||||
|
|
||||||
// 가중치 합성 (Tech 35% : Fin 25% : News 20% : Sys 20%)
|
val hasNews = tempDecision.newsContext != null && tempDecision.newsContext!!.isNotBlank()
|
||||||
var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.25) + (newsScore100 * 0.30) + (sysScore100 * 0.20)
|
|
||||||
// var finalConfidence = (finScore100 * 0.25) + (techScore100 * 0.35) + (newsScore100 * 0.20) + (sysScore100 * 0.20)
|
var finalConfidence = if (hasNews) {
|
||||||
|
// 뉴스가 있을 때: 원래 로직
|
||||||
|
(finScore100 * 0.25) + (techScore100 * 0.25) + (newsScore100 * 0.30) + (sysScore100 * 0.20)
|
||||||
|
} else {
|
||||||
|
// 뉴스가 없을 때: 기술 45%, 재무 35%, 시스템 20%로 배분 (뉴스 영향력 제거)
|
||||||
|
(finScore100 * 0.35) + (techScore100 * 0.45) + (sysScore100 * 0.20)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempDecision.isWatering) {
|
||||||
|
// 물타기 대상이 여기까지 왔다는 건 '볼린저 하단 터치'나 '초과매도(RSI<35)' 등 바닥 확인이 끝났다는 뜻임.
|
||||||
|
// 깎인 기술 점수를 보완하기 위해 강한 턴어라운드 기대 가점을 부여
|
||||||
|
finalConfidence += 20.0
|
||||||
|
|
||||||
|
// 물타기 전용 등급 강제 상향 (잡주가 아니라는 전제 하에)
|
||||||
|
if (finalConfidence >= KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) * 0.8) {
|
||||||
|
// 물타기 전용 등급(예: 신규 매수 로직에 안 잡히게 LEVEL_3 정도로 고정)
|
||||||
|
tempDecision.investmentGrade = InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 보너스 및 패널티 로직
|
// 보너스 및 패널티 로직
|
||||||
if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0
|
if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0
|
||||||
@@ -560,6 +582,7 @@ object RagService {
|
|||||||
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
||||||
|
|
||||||
return TradingDecision().apply {
|
return TradingDecision().apply {
|
||||||
|
this.analyzer = tempDecision.analyzer
|
||||||
this.technicalScore = techScore100
|
this.technicalScore = techScore100
|
||||||
this.financialScore = finScore100
|
this.financialScore = finScore100
|
||||||
this.systemScore = sysScore100
|
this.systemScore = sysScore100
|
||||||
@@ -567,6 +590,7 @@ object RagService {
|
|||||||
this.stockName = stockName
|
this.stockName = stockName
|
||||||
this.currentPrice = tempDecision.currentPrice
|
this.currentPrice = tempDecision.currentPrice
|
||||||
this.techSummary = tempDecision.techSummary
|
this.techSummary = tempDecision.techSummary
|
||||||
|
this.isWatering = tempDecision.isWatering
|
||||||
this.ultraShortScore = scores.ultraShort.toDouble()
|
this.ultraShortScore = scores.ultraShort.toDouble()
|
||||||
this.shortTermScore = scores.shortTerm.toDouble()
|
this.shortTermScore = scores.shortTerm.toDouble()
|
||||||
this.midTermScore = scores.midTerm.toDouble()
|
this.midTermScore = scores.midTerm.toDouble()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@ import network.StockUniverseLoader
|
|||||||
import service.AutoTradingManager
|
import service.AutoTradingManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -509,7 +510,28 @@ fun TradingDecisionLog() {
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp))
|
Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "매수 분석 현 변동율 처저 기준",
|
||||||
|
initialValue = (tradeConfig.minusFilter * -1).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.minusFilter = abs(it.toDouble())
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "현제 변동율이 이것보다 커야 분석 함."
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "매수 분석 현 변동율 최고 기준",
|
||||||
|
initialValue = (tradeConfig.plusFilter).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.plusFilter = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "현제 변동율이 이것보다 작아야 분석 함."
|
||||||
|
)
|
||||||
|
}
|
||||||
// Boolean 설정들
|
// Boolean 설정들
|
||||||
SettingSwitchField(
|
SettingSwitchField(
|
||||||
label = "미체결 자동 취소 (매수)",
|
label = "미체결 자동 취소 (매수)",
|
||||||
@@ -542,23 +564,37 @@ fun TradingDecisionLog() {
|
|||||||
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
|
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
SettingSwitchField (
|
SettingSwitchField (
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
label = "장 전 대체마켓 매도",
|
label = "장 전 대체마켓 매도",
|
||||||
initialChecked = tradeConfig.before_nxt,
|
initialChecked = tradeConfig.before_nxt,
|
||||||
onCheckedChange = { tradeConfig.before_nxt = it
|
onCheckedChange = {
|
||||||
KisSession.saveTradeConfig() }
|
tradeConfig.before_nxt = it
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingSwitchField(
|
SettingSwitchField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
label = "장 후 대체 마켓 매도",
|
label = "장 후 대체 마켓 매도",
|
||||||
initialChecked = tradeConfig.after_nxt,
|
initialChecked = tradeConfig.after_nxt,
|
||||||
onCheckedChange = { tradeConfig.after_nxt = it
|
onCheckedChange = {
|
||||||
KisSession.saveTradeConfig() }
|
tradeConfig.after_nxt = it
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// SettingSwitchField(
|
||||||
|
// label = "해외 주식",
|
||||||
|
// initialChecked = tradeConfig.enableOverSea,
|
||||||
|
// onCheckedChange = { tradeConfig.enableOverSea = it
|
||||||
|
// KisSession.saveTradeConfig() }
|
||||||
|
// )
|
||||||
SettingSwitchField(
|
SettingSwitchField(
|
||||||
label = "해외 주식",
|
label = "배당 주만 거래",
|
||||||
initialChecked = tradeConfig.enableOverSea,
|
initialChecked = tradeConfig.isUpcomingDividend,
|
||||||
onCheckedChange = { tradeConfig.enableOverSea = it
|
onCheckedChange = { tradeConfig.isUpcomingDividend = it
|
||||||
KisSession.saveTradeConfig() }
|
KisSession.saveTradeConfig() }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -571,6 +607,127 @@ fun TradingDecisionLog() {
|
|||||||
},
|
},
|
||||||
helperText = "본인의 텔레그램 아뒤"
|
helperText = "본인의 텔레그램 아뒤"
|
||||||
)
|
)
|
||||||
|
SettingSwitchField(
|
||||||
|
label = "물타기",
|
||||||
|
initialChecked = tradeConfig.lowerAveragePrice,
|
||||||
|
onCheckedChange = { tradeConfig.lowerAveragePrice = it
|
||||||
|
KisSession.saveTradeConfig() }
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "물타기 최저선",
|
||||||
|
initialValue = (tradeConfig.lowerAverageMaxRate).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.lowerAverageMaxRate = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "이것보다 커야 삼"
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "물타기 최고선",
|
||||||
|
initialValue = (tradeConfig.lowerAverageMinRate).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.lowerAverageMinRate = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "이것보다 작아야 삼"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "물타기 기준 최소 보유 수량",
|
||||||
|
initialValue = (tradeConfig.lowerAverageTargetCount).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.lowerAverageTargetCount = it.toInt()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "이거 이상 갖고 있어야 삼"
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "물타기 개수",
|
||||||
|
initialValue = (tradeConfig.lowerAverageStockCount).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.lowerAverageStockCount = it.toInt()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "1만큼 사고 팜"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingSwitchField(
|
||||||
|
label = "아침 자동 매도 주문",
|
||||||
|
initialChecked = tradeConfig.autoSellOrder,
|
||||||
|
onCheckedChange = { tradeConfig.autoSellOrder = it
|
||||||
|
KisSession.saveTradeConfig() }
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "자동 매도 기준 최저가",
|
||||||
|
initialValue = (tradeConfig.autoSellOrderMin).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.autoSellOrderMin = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "이것보다 작아야 주문"
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "자동 매도 기준 최고가",
|
||||||
|
initialValue = (tradeConfig.autoSellOrderMax).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.autoSellOrderMax = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "이것보다 커야 주문"
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "매입가 기준 호가위로 주문",
|
||||||
|
initialValue = (tradeConfig.autoSellOrderAppend).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.autoSellOrderAppend = it.toInt()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "위의 수치 만큼 호가 위로 주문함."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "예상 수익율",
|
||||||
|
initialValue = (tradeConfig.minExpectedProfitRate).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.minExpectedProfitRate = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "현제가 기준 예상 수익율이 더커야 삼"
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "예상 매도 기준일",
|
||||||
|
initialValue = (tradeConfig.maxExpectedReboundDays).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.maxExpectedReboundDays = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "이정도 일수 전에는 팔릴거라 예상"
|
||||||
|
)
|
||||||
|
SettingInputField(
|
||||||
|
modifier = Modifier.weight(1.0f, true),
|
||||||
|
label = "주식 널뛰기 기준(예상 매도 기준일)",
|
||||||
|
initialValue = (tradeConfig.minExpectedReboundDays).toString(),
|
||||||
|
onSave = {
|
||||||
|
tradeConfig.minExpectedReboundDays = it.toDouble()
|
||||||
|
KisSession.saveTradeConfig()
|
||||||
|
},
|
||||||
|
helperText = "너무 작으면 초단타,널뛰기, 이 보다 커야 분석함"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,9 +873,61 @@ fun CsvDropZone(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
//@Composable
|
||||||
|
//fun SettingInputField(
|
||||||
|
// label: String,
|
||||||
|
// initialValue: String, // 💡 value -> initialValue 로 변경
|
||||||
|
// placeholder: String = "",
|
||||||
|
// helperText: String = "",
|
||||||
|
// onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경
|
||||||
|
//) {
|
||||||
|
// // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
|
||||||
|
// var localText by remember { mutableStateOf(initialValue) }
|
||||||
|
//
|
||||||
|
// Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// OutlinedTextField(
|
||||||
|
// value = localText,
|
||||||
|
// onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
|
||||||
|
// label = { Text(label, fontWeight = FontWeight.Bold) },
|
||||||
|
// placeholder = { Text(placeholder) },
|
||||||
|
// modifier = Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .onFocusChanged { focusState ->
|
||||||
|
// // 💡 포커스를 잃었을 때 (다른 칸을 클릭했을 때) 저장
|
||||||
|
// if (!focusState.isFocused) {
|
||||||
|
// onSave(localText)
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// singleLine = true,
|
||||||
|
// keyboardOptions = KeyboardOptions(
|
||||||
|
// imeAction = ImeAction.Done,
|
||||||
|
// keyboardType = KeyboardType.Decimal
|
||||||
|
// ),
|
||||||
|
// keyboardActions = KeyboardActions(
|
||||||
|
// // 💡 모바일 키보드나 키보드에서 엔터(Done) 쳤을 때 저장
|
||||||
|
// onDone = {
|
||||||
|
// onSave(localText)
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// if (helperText.isNotEmpty()) {
|
||||||
|
// Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Text(
|
||||||
|
// text = helperText,
|
||||||
|
// color = Color.Gray,
|
||||||
|
// fontSize = 11.sp,
|
||||||
|
// modifier = Modifier.padding(start = 4.dp)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingInputField(
|
fun SettingInputField(
|
||||||
|
modifier: Modifier? = null,
|
||||||
label: String,
|
label: String,
|
||||||
initialValue: String, // 💡 value -> initialValue 로 변경
|
initialValue: String, // 💡 value -> initialValue 로 변경
|
||||||
placeholder: String = "",
|
placeholder: String = "",
|
||||||
@@ -728,7 +937,7 @@ fun SettingInputField(
|
|||||||
// 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
|
// 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책)
|
||||||
var localText by remember { mutableStateOf(initialValue) }
|
var localText by remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = modifier ?: Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = localText,
|
value = localText,
|
||||||
onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
|
onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영
|
||||||
@@ -767,8 +976,12 @@ fun SettingInputField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingSwitchField(
|
fun SettingSwitchField(
|
||||||
|
modifier :Modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp, horizontal = 4.dp),
|
||||||
label: String,
|
label: String,
|
||||||
initialChecked: Boolean,
|
initialChecked: Boolean,
|
||||||
helperText: String = "",
|
helperText: String = "",
|
||||||
@@ -778,9 +991,7 @@ fun SettingSwitchField(
|
|||||||
var localChecked by remember { mutableStateOf(initialChecked) }
|
var localChecked by remember { mutableStateOf(initialChecked) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp, horizontal = 4.dp)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import java.time.ZoneId
|
|||||||
|
|
||||||
object MarketUtil {
|
object MarketUtil {
|
||||||
private var isHolidayCached: Boolean? = null // 하루 한 번만 체크하기 위한 캐시
|
private var isHolidayCached: Boolean? = null // 하루 한 번만 체크하기 위한 캐시
|
||||||
|
var canTradeDays = hashMapOf<String, Boolean>()
|
||||||
suspend fun canTradeToday(): Boolean {
|
suspend fun canTradeToday(): Boolean {
|
||||||
val seoulZone = java.time.ZoneId.of("Asia/Seoul")
|
val seoulZone = java.time.ZoneId.of("Asia/Seoul")
|
||||||
val now = java.time.ZonedDateTime.now(seoulZone)
|
val now = java.time.ZonedDateTime.now(seoulZone)
|
||||||
@@ -16,25 +16,29 @@ object MarketUtil {
|
|||||||
val dayOfWeek = now.dayOfWeek.value
|
val dayOfWeek = now.dayOfWeek.value
|
||||||
if (dayOfWeek >= 6) return false
|
if (dayOfWeek >= 6) return false
|
||||||
// 1. 주말 체크 (토, 일)
|
// 1. 주말 체크 (토, 일)
|
||||||
val cachedHoliday = DatabaseFactory.getHoliday(todayStr)
|
return true
|
||||||
if (cachedHoliday != null) {
|
// try {
|
||||||
println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(cachedHoliday) "휴장" else "영업일"}")
|
// if (canTradeDays.contains(todayStr)) {
|
||||||
return !cachedHoliday
|
// println("📂 [DB Cache] 오늘($todayStr)의 휴장 여부를 DB에서 로드했습니다: ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}")
|
||||||
}
|
// return canTradeDays.get(todayStr) == true
|
||||||
|
// }
|
||||||
// 3. DB에 없으면 API 호출
|
// } catch (e: Exception) {e.printStackTrace()}
|
||||||
return try {
|
//
|
||||||
val result = KisTradeService.fetchIsHoliday(todayStr)
|
//
|
||||||
val isHoliday = result.getOrDefault(true)
|
// // 3. DB에 없으면 API 호출
|
||||||
|
// return try {
|
||||||
// 결과를 DB에 저장하여 다음 실행 시 재사용
|
// val result = KisTradeService.fetchIsHoliday(todayStr)
|
||||||
DatabaseFactory.saveHoliday(todayStr, isHoliday)
|
// val canTrade = result.getOrDefault(false)
|
||||||
|
//
|
||||||
println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다.")
|
// // 결과를 DB에 저장하여 다음 실행 시 재사용
|
||||||
!isHoliday
|
// canTradeDays.put(todayStr, canTrade)
|
||||||
} catch (e: Exception) {
|
//
|
||||||
false
|
// println("🌐 [API Call] 오늘($todayStr)의 휴장 여부를 새로 조회하여 DB에 저장했습니다. ${if(canTradeDays.get(todayStr) == false) "휴장" else "영업일"}" )
|
||||||
}
|
// canTrade
|
||||||
|
// } catch (e: Exception) {
|
||||||
|
// e.printStackTrace()
|
||||||
|
// false
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isKoreanMarketOpen(): Boolean {
|
fun isKoreanMarketOpen(): Boolean {
|
||||||
|
|||||||
@@ -11378,5 +11378,21 @@
|
|||||||
{
|
{
|
||||||
"code": "408470",
|
"code": "408470",
|
||||||
"name": "한패스"
|
"name": "한패스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "403810",
|
||||||
|
"name": "아이엘로보틱스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "059180",
|
||||||
|
"name": "엔더블유시"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "950250",
|
||||||
|
"name": "테라뷰"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "288180",
|
||||||
|
"name": "케이피항공산업"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in New Issue
Block a user