Compare commits

...
61 Commits
Author SHA1 Message Date
lun_admin bcdb3546fa 분석후 분할 매수, 보유주식 분할 매도 2026-09-16 17:35:06 +09:00
lun_admin 3827698b6f ... 2026-09-15 17:35:11 +09:00
lun_admin c84f4f0cd1 ... 2026-09-15 17:04:57 +09:00
lun_admin a53beb1e18 ... 2026-09-15 16:53:47 +09:00
lun_admin 15793d1852 ... 2026-09-15 16:37:18 +09:00
lun_admin 84f2202f17 ... 2026-09-15 16:17:45 +09:00
lun_admin 1a2fe8b6e6 ..... 2026-09-15 15:35:27 +09:00
lun_admin 0e241205ee ... 2026-09-15 14:29:47 +09:00
lun_admin 8e567f5b69 .... 2026-09-15 14:26:53 +09:00
lun_admin 442cdf0877 .. 2026-09-15 11:37:09 +09:00
lun_admin 3998cf159a .... 2026-09-08 11:30:43 +09:00
lun_admin 5e90ee39bc ... 2026-09-07 14:18:23 +09:00
lun_admin 5882f07e42 ... 2026-08-12 15:22:22 +09:00
lun_admin 302802a53d .... 2026-08-10 17:23:40 +09:00
lun_admin b83eb11cb7 ../. 2026-08-10 11:29:59 +09:00
lun_admin 30ab7bbecf .. 2026-08-10 10:15:13 +09:00
lun_admin 3fdb298caa ... 2026-08-03 16:17:53 +09:00
lun_admin acd26abe1e .... 2026-08-03 11:53:29 +09:00
lun_admin b134dea1e1 ... 2026-07-10 14:55:19 +09:00
lun_admin 7474558136 ... 2026-06-26 10:17:03 +09:00
lun_admin 81ce9b1539 . 2026-06-19 18:08:15 +09:00
lun_admin c55b089fcd ... 2026-06-19 15:17:20 +09:00
lun_admin fd8283c507 ... 2026-06-19 14:40:46 +09:00
lun_admin ce07537eef ... 2026-06-19 14:31:33 +09:00
lun_admin 8e145803d8 .... 2026-06-18 17:56:34 +09:00
lun_admin 27d330677e ... 2026-06-16 13:07:38 +09:00
lun_admin 71fdabfc32 ... 2026-06-15 14:28:12 +09:00
lun_admin 3fd9e3d833 ..... 2026-06-12 13:11:23 +09:00
lun_admin affad2743e .... 2026-06-09 14:16:18 +09:00
lun_admin 075a085b92 ... 2026-06-08 14:45:15 +09:00
lun_admin 355db7fe20 ... 2026-06-04 11:37:08 +09:00
lun_admin 4a72a30ab6 ... 2026-06-04 11:26:33 +09:00
lun_admin df5febfb42 ... 2026-06-04 10:51:24 +09:00
lun_admin aa4f8daadf ... 2026-06-04 10:42:54 +09:00
lun_admin d57f1698af ... 2026-06-03 13:27:05 +09:00
lun_admin 91b616e127 ... 2026-06-02 17:57:00 +09:00
lun_admin 21242d5ca4 ... 2026-06-02 17:10:06 +09:00
lun_admin d26eb34f1d ... 2026-06-02 15:30:17 +09:00
lun_admin 83d671bece ... 2026-06-02 15:05:02 +09:00
lun_admin 059d1830b7 ... 2026-06-02 14:43:13 +09:00
lun_admin ada0d9d6fe ... 2026-06-02 14:40:33 +09:00
lun_admin 9d43c04670 ... 2026-06-02 11:19:31 +09:00
lun_admin d0bdc57a1e ... 2026-06-02 10:43:37 +09:00
lun_admin 558a39e2d3 ... 2026-06-01 17:55:10 +09:00
lun_admin 510e19b2e8 .... 2026-06-01 13:37:07 +09:00
lun_admin 3fcce5e5c6 .... 2026-05-29 13:29:17 +09:00
lun_admin 74c3e2462d ... 2026-05-27 12:45:19 +09:00
lun_admin ef32260bdb ... 2026-05-26 14:18:41 +09:00
lun_admin 1cca11edc4 .... 2026-05-26 11:20:37 +09:00
lun_admin e94869b9e7 ... 2026-05-20 17:18:15 +09:00
lun_admin ce63b5760a ... 2026-05-19 13:33:59 +09:00
lun_admin 9af9f46748 ... 2026-05-18 17:56:26 +09:00
lun_admin 4c27d71701 ... 2026-05-18 13:03:10 +09:00
lun_admin b9ba8efc1a ... 2026-05-14 13:27:33 +09:00
lun_admin ad6d00ac39 ... 2026-05-13 11:37:57 +09:00
lun_admin 619407966e ... 2026-05-07 14:53:19 +09:00
lun_admin 6f98b0fde4 .. 2026-05-06 17:21:45 +09:00
lun_admin 5c00152de9 .. 2026-05-06 17:13:59 +09:00
lun_admin 07a66a3fa3 .. 2026-05-06 16:43:49 +09:00
lun_admin d7efc433bd .. 2026-05-06 15:53:55 +09:00
lun_admin 0413fa3e2e .. 2026-05-04 11:19:52 +09:00
21 changed files with 2587 additions and 496 deletions
+9 -2
View File
@@ -88,7 +88,12 @@ fun getLlamaBinPath(): String {
}
// Windows NUC
os.contains("win") -> {
"$basePath/win-x64-n/llama-server.exe"
if (KisSession.tradeConfig.isLowPerformanceMonitoring) {
"$basePath/win-x64/llama-server.exe"
}
else {
"$basePath/win-x64-n/llama-server.exe"
}
}
else -> "$basePath/llama-server"
}
@@ -111,6 +116,7 @@ private var isAppStarted = false
fun main() = application {
if (!isAppStarted) {
initLogger(DETAILLOG)
KisSession.tradeConfig = KisSession.loadTradeConfig()
try {
val (port1, port2) = PortFinder.findAvailablePortPair(18080, false)
if (port1 > 18000 && port2 > port1) {
@@ -307,7 +313,7 @@ fun main() = application {
AutoTradingManager.isSystemCleanedUpToday = false
CoroutineScope(Dispatchers.Default).launch {
AutoTradingManager.startAutoDiscoveryLoop()
AutoTradingManager.startAutoDiscoveryLoop(true)
KisWebSocketManager.onExecutionReceived = AutoTradingManager.onExecutionReceived
KisWebSocketManager.connect()
}
@@ -328,6 +334,7 @@ fun main() = application {
// DashboardScreen()
}
AppScreen.TradingDecision -> {
TradingDecisionLog()
}
}
+50
View File
@@ -0,0 +1,50 @@
package analyzer
import model.CurrentPriceOutput
import model.RiskValidationResult
object RiskManager {
/**
* 통합된 시세 데이터를 바탕으로 매수 불가능한 '위험 종목'을 걸러냅니다.
* @param data API에서 받아온 통합 CurrentPriceOutput 객체
* @return RiskValidationResult (isSafe == true 이면 매수 진행)
*/
fun evaluateRisk(data: CurrentPriceOutput): RiskValidationResult {
// 1. 거래 정지 및 임시 정지 확인
if (data.iscd_stat_cls_code == "58" || data.temp_stop_yn.uppercase() == "Y") {
return RiskValidationResult(false, "거래정지 또는 임시정지 종목")
}
// 2. 상장폐지 직전 단계 (정리매매, 관리종목)
if (data.sltr_yn.uppercase() == "Y") {
return RiskValidationResult(false, "정리매매 진행 중인 종목")
}
if (data.iscd_stat_cls_code == "51" || data.mang_issu_cls_code == "1" || data.mang_issu_cls_code.uppercase() == "Y") {
return RiskValidationResult(false, "상장폐지 위험이 있는 관리종목")
}
// 3. 거래소 경고 (투자위험, 투자경고, 투자주의, 단기과열종목)
val warningCodes = listOf("52", "53", "54", "59")
if (warningCodes.contains(data.iscd_stat_cls_code)) {
return RiskValidationResult(false, "거래소 경고 상태 (상태코드: ${data.iscd_stat_cls_code})")
}
if (data.short_over_yn.uppercase() == "Y") {
return RiskValidationResult(false, "단기과열 지정 종목")
}
if (data.invt_caful_yn.uppercase() == "Y") {
return RiskValidationResult(false, "투자유의 지정 종목")
}
// 4. 시장경고코드 필터링 (정상 상태인 "00"이나 빈 값이 아닌 경우)
if (data.mrkt_warn_cls_code.isNotEmpty() && data.mrkt_warn_cls_code != "00") {
return RiskValidationResult(false, "시장경고 발동 중 (경고코드: ${data.mrkt_warn_cls_code})")
}
// 모든 위험 요소를 무사히 통과한 경우
return RiskValidationResult(true, "안전")
}
}
+547 -13
View File
@@ -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개 캔들)
@@ -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 {
if (daily.size < 20) return false
val currentPrice = daily.last().stck_prpr.toDouble()
// 1. 일봉 20일선 이격도
// 1. 기본 지표 계산
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
val disparityDaily = (currentPrice / ma20Daily) * 100
// 2. 일봉 RSI (단순 이격도 외의 과열 여부 확인)
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
if (min30.size >= 10) {
val ma10Min = min30.takeLast(10).map { it.stck_prpr.toDouble() }.average()
val disparityMin = (currentPrice / ma10Min) * 100
isMicroOverheated = disparityMin > 105.0 // 10분 평균가 대비 순간적으로 5% 이상 폭등 시
isMicroOverheated = disparityMin > microPumpThreshold
}
// 단순히 이격도 115%라고 막는 것이 아니라 3가지 깐깐한 조건 중 하나라도 충족될 때만 과열 판정
return disparityDaily > 130.0 || // (A) 역대급 폭등 상태
(disparityDaily > 115.0 && rsiDaily > 75.0) || // (B) 급등 중이면서 일봉 과매수
(disparityDaily > 115.0 && isMicroOverheated) // (C) 급등 중이면서 10분 내 순간 펌핑(꼭지)
// 5. 최종 세분화 판정 로직
return disparityDaily > adjustedMaxDisparity || // (A) 종목 체급 대비 역대급 폭등 상태
(disparityDaily > adjustedWarnDisparity && rsiDaily > 75.0) || // (B) 체급별 경고 수준 + 일봉 과매수
(disparityDaily > adjustedWarnDisparity && isMicroOverheated) // (C) 체급별 경고 수준 + 초단기 펌핑(꼭지)
}
fun calculateScores(financialScore100: Int): InvestmentScores {
@@ -157,6 +341,48 @@ 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 = 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 {
if (candles.size < period + 1) return 50.0
@@ -241,4 +467,312 @@ $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)을 계산합니다.
*/
/**
* [개선] 종목의 현재 하락 진행 상태와 통계적 예상 바닥(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
)
+114 -6
View File
@@ -1,10 +1,17 @@
import androidx.compose.runtime.mutableStateListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import model.AppConfig
import model.KisSession
import model.TradingDecision
import network.NewsService
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.javatime.datetime
import org.jetbrains.exposed.sql.transactions.experimental.suspendedTransactionAsync
import org.jetbrains.exposed.sql.transactions.transaction
import report.TradingReportManager
import report.TradingReportService
@@ -235,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) }
}
}
@@ -484,7 +491,18 @@ object TradingLogStore {
decision = decision.decision ?: "HOLD",
confidence = decision.confidence,
reason = decision.reason ?: ""
))
).apply {
if (KisSession.tradeConfig.useTagsShare.contains(this.decision) && KisSession.tradeConfig.useLogKeywordsShare.any {
reason.contains(
it
)
}) {
CoroutineScope(Dispatchers.Default).launch {
NewsService.sendTelegramMessage("${this@apply.decision}$stockName ${reason}")
}
}
}
)
}
}
@@ -498,26 +516,76 @@ object TradingLogStore {
decision = decision,
confidence = 100.0,
reason = log
)
).apply {
if (KisSession.tradeConfig.useTagsShare.contains(decision) && KisSession.tradeConfig.useLogKeywordsShare.any {
log.contains(
it
)
}) {
CoroutineScope(Dispatchers.Default).launch {
println("CALLED sendTelegramMessage")
NewsService.sendTelegramMessage("${this@apply.decision}$stockName ${log}")
}
}
}
)
}
}
fun addLog(tradingDecision: TradingDecision, decision: String, log: String) {
synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
decisionLogs.add(
LogEntry(
time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
stockName = "${tradingDecision.stockName}[${tradingDecision.currentPrice}][]",
stockName = "${tradingDecision.stockName}[${tradingDecision.currentPrice}]",
decision = decision,
confidence = tradingDecision.confidence,
reason = log
)
).apply {
CoroutineScope(Dispatchers.Default).launch {
if (((tradingDecision.investmentGrade?.name?.length ?: 0) > 0 && KisSession.tradeConfig.useGradeShare.any {
tradingDecision.investmentGrade?.name?.contains(
it
) ?: false
})) {
NewsService.sendTelegramMessage("${this@apply.decision} ${tradingDecision.stockName}[${tradingDecision.currentPrice}] ${log}")
}
}
}
)
}
}
fun addWatchLog(tradingDecision: TradingDecision, decision: String, log: String) {
synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
decisionLogs.add(
LogEntry(
time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
stockName = "${tradingDecision.stockName}[${tradingDecision.currentPrice}]",
decision = decision,
confidence = tradingDecision.confidence,
reason = log
).apply {
CoroutineScope(Dispatchers.Default).launch {
if (KisSession.tradeConfig.useTagsShare.contains(decision) && KisSession.tradeConfig.useLogKeywordsShare.any {
log.contains(
it
)
}) {
NewsService.sendTelegramMessage("${this@apply.decision} ${tradingDecision.stockName}[${tradingDecision.currentPrice}] ${log}")
}
}
}
)
}
}
fun addAnalyzer(name : String, code : String, log: String, positive : Boolean = false) {
synchronized(this) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
@@ -529,13 +597,39 @@ object TradingLogStore {
decision = if (positive) "ANALYZER" else "PASS",
confidence = 100.0,
reason = log
)
).apply {
if (KisSession.tradeConfig.useTagsShare.contains(decision) && KisSession.tradeConfig.useLogKeywordsShare.any {
log.contains(
it
)
}) {
CoroutineScope(Dispatchers.Default).launch {
NewsService.sendTelegramMessage("${this@apply.decision}$name[$code] ${log}")
}
}
}
)
}
}
}
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(
@@ -547,9 +641,23 @@ object TradingLogStore {
reason = log
)
)
}
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) {
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
+37
View File
@@ -240,6 +240,34 @@ class TradeConfig {
var start_buy_time : String = "08:55"
var end_buy_time : String = "15:10"
var enableOverSea : Boolean = false
var tlg_id : String = ""
var CYCLE_TIMEOUT = 15 * 60 * 1000L // 한 사이클 최대 10분
var WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
var STUCK_THRESHOLD = 7 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
var ONE_STOCK_ALYSIS_TIME = 180000L
var isLowPerformanceMonitoring: Boolean = false
var useGradeShare : List<String> = listOf("LEVEL_4","LEVEL_5")
var useTagsShare : List<String> = listOf("NOTICE", "WATCH","매도 완료")
var useLogKeywordsShare : List<String> = listOf("재분석")
var useAutoRepost : Boolean = false
var minusFilter : Double = 15.0
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
}
@@ -332,4 +360,13 @@ object KisSession {
fun isAvailBuyTime(now: LocalTime) : Boolean {
return now.isBefore(endBuyTime()) && now.isAfter(startBuyTime())
}
fun isMarketOpenTime(now: LocalTime) : Boolean {
return now.isBefore(LocalTime.of(15,30)) && now.isAfter(LocalTime.of(9,0))
}
fun isMarketAnalyzerTime(now: LocalTime) : Boolean {
return now.isBefore(LocalTime.of(19,0)) && now.isAfter(LocalTime.of(8,0))
}
}
+15
View File
@@ -38,6 +38,21 @@ cntg_vol : $cntg_vol
acml_tr_pbmn : $acml_tr_pbmn
""".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(
+43 -15
View File
@@ -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
@@ -237,6 +247,7 @@ data class ExecutionData(
)
@Serializable
data class CurrentPriceResponse(
val rt_cd: String, // 0: 성공, 0 이외: 실패
val msg_cd: String,
@@ -244,19 +255,36 @@ data class CurrentPriceResponse(
val output: CurrentPriceOutput
)
@Serializable
data class CurrentPriceOutput(
val stck_prpr: String, // 주식 현재가
val prdy_vrss: String, // 전일 대비
val prdy_ctrt: String, // 전일 대비율
val acml_vol: String, // 누적 거래량
val stck_oprc: String, // 시가
val stck_hgpr: String, // 고가
val stck_lwpr: String, //
val hts_avls: String, // 시가총액
val per: String,
val pbr: String,
val stck_shrn_iscd: String // 종목 코드
// ... 필요한 필드가 있다면 Python 모델을 참고하여 추가하세요.
// --- 📊 기존 시세 및 기본 펀더멘털 필드 ---
val stck_shrn_iscd: String = "", // 종목 코드
val stck_prpr: String = "0", // 주식 현재가
val prdy_vrss: String = "0", // 전일 대비
val prdy_ctrt: String = "0.0", // 전일 대비율
val acml_vol: String = "0", // 누적 거래량
val stck_oprc: String = "0", //
val stck_hgpr: String = "0", // 고가
val stck_lwpr: String = "0", // 저가
val hts_avls: String = "0", // 시가총액
val per: String = "0.0",
val pbr: String = "0.0",
// --- 🚨 신규 추가: 리스크 필터링 및 상태 필드 ---
val rprs_mrkt_kor_name: String = "", // 대표 시장 한글 명 (KOSPI, KOSDAQ 등)
val iscd_stat_cls_code: String = "", // 종목 상태 구분 코드 (51:관리, 52:위험, 58:정지 등)
val mrkt_warn_cls_code: String = "", // 시장경고코드 (보통 "00"이 정상)
val short_over_yn: String = "", // 단기과열여부 (Y/N)
val sltr_yn: String = "", // 정리매매여부 (Y/N)
val mang_issu_cls_code: String = "", // 관리종목여부 (1/0 또는 Y/N)
val temp_stop_yn: String = "", // 임시 정지 여부 (Y/N)
val invt_caful_yn: String = "" // 투자유의여부 (Y/N)
)
// 필터링 결과를 담을 객체
data class RiskValidationResult(
val isSafe: Boolean,
val rejectReason: String = ""
)
+11 -1
View File
@@ -43,6 +43,12 @@ class TradingDecision {
var financialData : String? = null
var analyzer : TechnicalAnalyzer? = 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() =
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()
}
}
+24 -2
View File
@@ -17,6 +17,8 @@ import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
import kotlinx.serialization.encodeToString // 추가 필요
import java.nio.charset.Charset
@Serializable
data class StockItem(
val code: String,
@@ -29,6 +31,25 @@ object StockUniverseLoader {
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
private const val DEFAULT_FILE_PATH = "stocks_universe.json"
fun readSafeLines(file: File): List<String> {
val eucKr = Charset.forName("EUC-KR")
val utf8 = Charsets.UTF_8
// 우선 EUC-KR로 읽어봄
val lines = file.readLines(eucKr)
// 첫 줄에서 한글이 깨졌는지 검사 (정규식 활용)
// 한글이 하나도 없고 깨진 특수문자만 있다면 UTF-8로 재시도
val hasKorean = lines.firstOrNull()?.any { it in '\uAC00'..'\uD7A3' } ?: false
return if (hasKorean) {
lines
} else {
println("⚠️ EUC-KR에서 한글 미검출. UTF-8로 재시도합니다.")
file.readLines(utf8)
}
}
fun loadUniverse(filePath: String = DEFAULT_FILE_PATH): List<Pair<String, String>> {
return try {
val file = File(filePath)
@@ -50,7 +71,8 @@ object StockUniverseLoader {
try {
val stockItems = items.map { StockItem(it.first, it.second) }
val jsonString = json.encodeToString(stockItems)
File(filePath).writeText(jsonString)
// File(filePath).writeText(jsonString)
File(filePath).writeText(jsonString, Charsets.UTF_8)
println("💾 [System] 유니버스 영구 저장 완료: 총 ${items.size}종목")
} catch (e: Exception) {
println("❌ 유니버스 저장 실패: ${e.message}")
@@ -61,7 +83,7 @@ object StockUniverseLoader {
fun parseAndMergeCsv(file: File, targetJsonPath: String = DEFAULT_FILE_PATH): List<Pair<String, String>> {
val newItems = mutableListOf<Pair<String, String>>()
try {
val lines = file.readLines()
val lines = readSafeLines(file)
if (lines.isEmpty()) return loadUniverse(targetJsonPath)
// 헤더 자동 추적
+173 -25
View File
@@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonPrimitive
import model.*
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.coroutineContext
@@ -45,7 +46,7 @@ object KisTradeService {
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.NONE
level = LogLevel.ALL
}
}
@@ -69,7 +70,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,15 +123,18 @@ object KisTradeService {
} else 0.0
// 3. 모델 생성
Result.success(UnifiedBalance(
totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)),
deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L),
dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)),
todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)),
totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달
holdings = combinedHoldings
))
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),
dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)),
todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)),
totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달
holdings = combinedHoldings
))
} else {
Result.failure(Exception("combinedHoldings empty"))
}
} catch (e: Exception) {
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"(월)
@@ -234,8 +310,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 +320,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)
@@ -259,7 +335,7 @@ object KisTradeService {
parameter("FID_INPUT_DATE_1", startDate)
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_PERIOD_DIV_CODE", periodCode) // D, W, M
parameter("FID_ORG_ADJ_PRC", "0")
@@ -331,7 +407,7 @@ object KisTradeService {
stockCode: String,
qty: String,
price: String,
isBuy: Boolean,
isBuy: Boolean = false,
orderDivision: String = "",
marketCode : String = "KRX"
): Result<String> {
@@ -351,12 +427,14 @@ object KisTradeService {
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
else -> if (isBuy) "TTTS3002U" else "TTTS3001U"
}
val finalOrderDivision = when {
var finalOrderDivision = when {
orderDivision.isNotEmpty() -> orderDivision
marketCode.equals("SOR") || price == "0" || price.isEmpty() -> "01" // 시장가
else -> "00" // 지정가
}
if (marketCode.equals("KRX") && LocalTime.now(ZoneId.of("Asia/Seoul")).isAfter(LocalTime.of(16,0))) {
finalOrderDivision = "41"
}
return try {
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")
// 파라미터 설정
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) // 종목코드
}
if (response.status.isSuccess()) {
val body = response.body<CurrentPriceResponse>()
if (body.rt_cd == "0") {
// println("${body.output}")
Result.success(body.output)
} else {
println("API 에러: ${body.msg1}")
Result.failure(Exception("API 에러: ${body.msg1}"))
}
} else {
println("HTTP 에러: ${response.status}")
Result.failure(Exception("HTTP 에러: ${response.status}"))
}
} catch (e: Exception) {
e.printStackTrace()
println("HTTP 에러: ${e.message}")
Result.failure(e)
}
}
@@ -604,11 +688,11 @@ object KisTradeService {
try {
do {
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") {
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,10 +711,14 @@ 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}\n${body.output2}\n\n")
println("✅ [Step $pageCount] $markgetCode 수신 완료 - 종목 수: ${body.output1.size}")
allHoldings.addAll(body.output1)
@@ -641,13 +729,13 @@ object KisTradeService {
ctxAreaFk = body.ctx_area_fk100 ?: ""
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") {
pageCount++
trCont = "N"
println("⏳ [연속 조회] 250ms 대기 후 다음 페이지 요청...")
delay(250) // API 과부하 방지
delay(500) // API 과부하 방지
}
} while (trCont == "N")
@@ -766,4 +854,64 @@ object KisTradeService {
}
suspend fun reserveSell(stockCode: String,
qty: String,
price: String,
targetDate : String) :Result<String> {
val config = KisSession.config
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
// 계좌번호 처리: 8자리면 01 자동 추가
var pureAccount = config.accountNo.replace("-", "").trim()
if (pureAccount.length == 8) pureAccount += "01"
val cano = pureAccount.take(8)
val acntPrdtCd = pureAccount.takeLast(2)
val trId = "CTSC0008U"
return try {
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-resv") {
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("tr_id", trId)
header("custtype", "P") // [해결] 필수 헤더 추가
header("Content-Type", "application/json")
setBody(mapOf(
"CANO" to cano,
"ACNT_PRDT_CD" to acntPrdtCd,
"PDNO" to stockCode,
"ORD_DVSN_CD" to "00",
"SLL_BUY_DVSN_CD" to "01",
"ORD_QTY" to qty,
"ORD_UNPR" to price,
"ORD_OBJT_CBLC_DVSN_CD" to "10",
"RSVN_ORD_END_DT" to targetDate
))
}
val body = response.body<JsonObject>() // [해결] Polymorphic 직렬화 에러 방지
val rtCd = body["rt_cd"]?.jsonPrimitive?.content
val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음"
if (rtCd == "0") {
// 응답의 output 객체에서 주문 번호(ODNO) 추출
val orderNo = body["output"]?.jsonObject?.get("ODNO")?.jsonPrimitive?.content
?: body["output"]?.jsonObject?.get("odno")?.jsonPrimitive?.content // API마다 대소문자가 다를 수 있음
?: ""
Result.success(orderNo) // 성공 시 주문 번호 반환
} else {
val msg = body["msg1"]?.jsonPrimitive?.content ?: "메시지 없음"
Result.failure(Exception("❌ 오류 ($rtCd): $msg"))
}
} catch (e: Exception) { Result.failure(e) }
}
}
@@ -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(
+68
View File
@@ -17,10 +17,14 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.ContentType.Application.Json
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Parameters
import io.ktor.http.Url
import io.ktor.http.contentType
import io.ktor.network.tls.TLSConfigBuilder
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -29,10 +33,16 @@ import model.KisSession
import model.NaverNewsResponse
import service.SafeScraper
import service.UrlCacheManager
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.URL
import java.net.URLEncoder
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import kotlin.Double
object NewsService {
@@ -122,4 +132,62 @@ object NewsService {
return ""
}
}
suspend fun sendTelegramMessage(data: String) {
Thread {
try {
var chatId = KisSession.tradeConfig.tlg_id
println("sendTelegramMessage $chatId")
sendViaSystemCurl("https://lunaticbum.kr/tlg/sendToMe.bjx",chatId,data)
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
}
fun sendViaSystemCurl(url : String, chatId: String, message: String) {
try {
// 메시지 내 공백이나 한글이 깨지지 않도록 인코딩 (필수)
val encodedMessage = URLEncoder.encode(message, "UTF-8")
// OS 확인
val isWindows = System.getProperty("os.name").lowercase().contains("win")
val command = if (isWindows) {
// 윈도우용: 큰따옴표 이스케이프에 주의해야 합니다.
val jsonBody = "{\"id\":\"$chatId\",\"message\":\"$encodedMessage\"}"
listOf("cmd", "/c", "curl -s -X POST $url -H \"Content-Type: application/json\" -d \"$jsonBody\"")
} else {
// 맥/리눅스용: 홑따옴표를 사용하여 JSON 구조를 보호합니다.
val jsonBody = "{\"id\":\"$chatId\",\"message\":\"$encodedMessage\"}"
listOf("curl", "-s", "-X", "POST", url, "-H", "Content-Type: application/json", "-d", jsonBody)
}
val process = ProcessBuilder(command)
.redirectErrorStream(true) // 에러 출력(stderr)을 표준 출력(stdout)으로 합침
.start()
// 프로세스의 출력을 읽어오는 블록
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
val output = StringBuilder()
var line: String?
while (reader.readLine().also { line = it } != null) {
output.append(line).append("\n")
}
val exitCode = process.waitFor() // 프로세스가 종료될 때까지 대기
println("--- Telegram Curl Log Start ---")
println("Exit Code: $exitCode") // 0이면 성공, 그 외는 curl 에러 코드
println("Response:\n$output")
println("--- Telegram Curl Log End ---")
}
} catch (e: Exception) {
println("시스템 명령어 실행 중 예외 발생: ${e.message}")
e.printStackTrace()
}
}
}
+33 -12
View File
@@ -237,7 +237,7 @@ object RagService {
}
}
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, isWatering : Boolean, result: TradingDecisionCallback) {
val totalStartTime = System.currentTimeMillis()
coroutineScope {
@@ -246,11 +246,12 @@ object RagService {
this.stockCode = stockCode
this.analyzer = technicalAnalyzer
this.currentPrice = currentPrice
this.isWatering = isWatering
}
if (isSafetyBeltStockCodes.contains(stockCode)) {
// 로그를 남기고 싶다면 주석 해제, 아니면 조용히 패스
// logTime(stockName, "재무 미달 (캐시) 조기 종료", 0, System.currentTimeMillis() - totalStartTime)
println("재무 안정성 부족 (캐시)")
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족 (캐시)" }, false)
return@coroutineScope
}
@@ -277,9 +278,11 @@ object RagService {
isSafetyBeltStockCodes.add(stockCode)
return@coroutineScope
}
if ((tradingDecision.signalModel?.compositeScore ?: 0) < 50) {
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
val techScore = tradingDecision.signalModel?.compositeScore ?: 0
// 🌟 신규 매수는 40점 컷오프, 물타기는 20점(또는 제한 없음)으로 하향
val minTechCutoff = if (tradingDecision.isWatering) 15 else 40
if (techScore < minTechCutoff) {
logTime(stockName, "기술 점수 미달 조기 종료 ${tradingDecision.signalModel?.compositeScore} , ${tradingDecision.signalModel?.successProbPct} ", techDuration, System.currentTimeMillis() - totalStartTime)
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt) && financialScore > 70) {
TradingLogStore.addAnalyzer(stockName, stockCode, "매수 타점 미도달 (재무 우량주로 감시 지속)", true)
result(tradingDecision.apply {
@@ -331,6 +334,7 @@ object RagService {
result(finalDecision, true)
} catch (e: Exception) {
e.printStackTrace()
println("❌ [$stockName] 분석 실패: ${e.message}")
}
}
@@ -431,7 +435,7 @@ object RagService {
put("type", "json_object")
}
}.toString()
println("requestBodyJson =>> $requestBodyJson")
// println("requestBodyJson =>> $requestBodyJson")
val request = Request.Builder()
.url(LLM_API_URL())
.post(requestBodyJson.toRequestBody(jsonMediaType))
@@ -486,9 +490,27 @@ object RagService {
val synthStartTime = System.currentTimeMillis()
val sysScore100 = calculateSystemPoint(scores) * 4.0
// 가중치 합성 (Tech 35% : Fin 25% : News 20% : Sys 20%)
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)
val hasNews = tempDecision.newsContext != null && tempDecision.newsContext!!.isNotBlank()
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
@@ -560,6 +582,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
@@ -567,6 +590,7 @@ object RagService {
this.stockName = stockName
this.currentPrice = tempDecision.currentPrice
this.techSummary = tempDecision.techSummary
this.isWatering = tempDecision.isWatering
this.ultraShortScore = scores.ultraShort.toDouble()
this.shortTermScore = scores.shortTerm.toDouble()
this.midTermScore = scores.midTerm.toDouble()
@@ -671,9 +695,6 @@ object RagService {
val alignmentBonus = if (s.ultraShort > s.shortTerm && s.shortTerm > s.midTerm) 3.0 else 0.0
return (base + alignmentBonus).coerceIn(0.0, 25.0)
}
}
@@ -59,6 +59,9 @@ object TradingReportManager : TradingReportService {
private val activePositions = mutableMapOf<String, String>()
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
if (!KisSession.tradeConfig.useAutoRepost) {
return
}
CoroutineScope(Dispatchers.IO).launch {
val todayDate = LocalDate.now().toString()
File diff suppressed because it is too large Load Diff
@@ -291,7 +291,7 @@ object SafeScraper {
private val totalRam = HardwareDetector.getTotalRamGb()
// RAM 8GB당 1개 수준으로 설정하되, 최대 10~12개로 제한 (CPU 부하 방지)
private val maxParallel = totalRam.div(6).toInt()
private val maxParallel = totalRam.div(4).toInt()
// 동시 처리를 1개로 줄여서 안정성을 극대화 (추천)
// Playwright는 여러 페이지를 띄울 때 CPU/메모리 점유율이 매우 높습니다.
@@ -82,22 +82,12 @@ object SystemSleepPreventer {
}
if (process?.isAlive == true) return
if (!isWin) {
// try {
// // -i: 시스템 절전 방지, -d: 디스플레이 취침 방지, -m: 디스크 유휴 상태 방지
// val command = listOf("caffeinate", "-i", "-d", "-m")
// process = ProcessBuilder(command).start()
// println("☕ [System] caffeinate 실행됨: 앱이 켜져 있는 동안 절전 모드가 방지됩니다.")
// } catch (e: Exception) {
// println("⚠️ [System] caffeinate 실행 실패: ${e.message}")
// }
}
start2()
}
fun start2() {
println("🚀 화면 잠금 방지 프로그램이 시작되었습니다. (작동 시간: $startTime ~ $endTime)")
// 1분(60초)마다 체크
scheduler.scheduleAtFixedRate({
if (isWorkingTime()) {
@@ -105,7 +95,7 @@ object SystemSleepPreventer {
} else {
println("💤 현재는 휴식 시간입니다. (${LocalTime.now().withNano(0)})")
}
}, 0, 60 * 2, TimeUnit.SECONDS)
}, 0, 150, TimeUnit.SECONDS)
}
private fun isWorkingTime(): Boolean {
@@ -136,21 +126,6 @@ object SystemSleepPreventer {
private val osName = System.getProperty("os.name").lowercase()
// 설정 시간
private val dimTime = LocalTime.of(16, 0) // 오후 4시 이후 최저 밝기
fun start3() {
scheduler.scheduleAtFixedRate({
val now = LocalTime.now()
// 16:00 이후라면 밝기를 낮춤
if (now.isAfter(dimTime) || now.isBefore(LocalTime.of(8, 30))) {
setBrightness(0)
} else {
setBrightness(100) // 업무 시간에는 다시 밝게 (80%)
}
}, 0, 10, TimeUnit.MINUTES) // 10분마다 체크
}
private fun setBrightness(level: Int) {
try {
+2
View File
@@ -45,6 +45,7 @@ import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.io.File
import androidx.compose.ui.input.key.*
import network.NewsService
fun getPastedPathFromClipboard(): String? {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
@@ -138,6 +139,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
SystemSleepPreventer.wakeDisplay() // 모니터 켜기
statusMessage = "⏰ 자동 실행 시간(08:30)입니다. 시스템을 가동합니다."
authenticateAndStart()
break // 성공하면 루프 탈출
}
}
+247 -17
View File
@@ -42,14 +42,18 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import model.ConfigIndex
import model.KisSession
import network.KisTradeService
import network.NewsService
import network.StockUniverseLoader
import service.AutoTradingManager
import java.io.File
import java.net.URI
import kotlin.math.abs
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -61,8 +65,13 @@ fun TradingDecisionLog() {
val filterOptions = listOf("전체", "BUY", "SELL", "SETTING","ANALYZER","WATCH","AFTER","NOTICE")//"PASS",,"RETRY""HOLD",
var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) }
val tradeConfig by remember {
KisSession.tradeConfig = KisSession.loadTradeConfig()
CoroutineScope(Dispatchers.Default).launch {
println("CALLED sendTelegramMessage -1")
val now = java.time.LocalTime.now(java.time.ZoneId.of("Asia/Seoul"))
NewsService.sendTelegramMessage("⏰ 자동 실행 시간(${now.hour}:${now.minute})입니다. 시스템을 가동합니다.")
}
mutableStateOf(KisSession.tradeConfig)
}
LaunchedEffect(AutoTradingManager.llmAnalyser) {
llmAnalyser = AutoTradingManager.llmAnalyser
@@ -501,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 = "미체결 자동 취소 (매수)",
@@ -534,25 +564,171 @@ fun TradingDecisionLog() {
helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소"
)
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
SettingSwitchField (
modifier = Modifier.weight(1.0f, true),
label = "장 전 대체마켓 매도",
initialChecked = tradeConfig.before_nxt,
onCheckedChange = {
tradeConfig.before_nxt = it
KisSession.saveTradeConfig()
}
)
SettingSwitchField(
modifier = Modifier.weight(1.0f, true),
label = "장 후 대체 마켓 매도",
initialChecked = tradeConfig.after_nxt,
onCheckedChange = {
tradeConfig.after_nxt = it
KisSession.saveTradeConfig()
}
)
}
// SettingSwitchField(
// label = "해외 주식",
// initialChecked = tradeConfig.enableOverSea,
// onCheckedChange = { tradeConfig.enableOverSea = it
// KisSession.saveTradeConfig() }
// )
SettingSwitchField(
label = "장 전 대체마켓 매도",
initialChecked = tradeConfig.before_nxt,
onCheckedChange = { tradeConfig.before_nxt = it
label = "배당 주만 거래",
initialChecked = tradeConfig.isUpcomingDividend,
onCheckedChange = { tradeConfig.isUpcomingDividend = it
KisSession.saveTradeConfig() }
)
SettingSwitchField(
label = "장 후 대체 마켓 매도",
initialChecked = tradeConfig.after_nxt,
onCheckedChange = { tradeConfig.after_nxt = it
KisSession.saveTradeConfig() }
SettingInputField(
label = "특정 메시지를 수신하려면 텔레그램 아뒤 입력",
initialValue = (tradeConfig.tlg_id).toString(),
onSave = {
tradeConfig.tlg_id = it
KisSession.saveTradeConfig()
},
helperText = "본인의 텔레그램 아뒤"
)
SettingSwitchField(
label = "해외 주식",
initialChecked = tradeConfig.enableOverSea,
onCheckedChange = { tradeConfig.enableOverSea = it
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 = "너무 작으면 초단타,널뛰기, 이 보다 커야 분석함"
)
}
}
}
}
@@ -697,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 = "",
@@ -709,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 }, // 타자 칠 때 화면 즉시 반영
@@ -748,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 = "",
@@ -759,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(),
+24 -20
View File
@@ -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 {
+156
View File
@@ -11238,5 +11238,161 @@
{
"code": "238490",
"name": "힘스"
},
{
"code": "000980",
"name": "교보19호스팩"
},
{
"code": "001320",
"name": "교보20호스팩"
},
{
"code": "478340",
"name": "나라스페이스테크놀로지"
},
{
"code": "403850",
"name": "더핑크퐁컴퍼니"
},
{
"code": "000010",
"name": "덕양에너젠"
},
{
"code": "491000",
"name": "리브스메드"
},
{
"code": "394420",
"name": "리센스메디컬"
},
{
"code": "000930",
"name": "미래에셋비전스팩8호"
},
{
"code": "000960",
"name": "미래에셋비전스팩9호"
},
{
"code": "488900",
"name": "비츠로넥스텍"
},
{
"code": "001150",
"name": "삼성스팩13호"
},
{
"code": "000130",
"name": "삼진식품"
},
{
"code": "061090",
"name": "세나테크놀로지"
},
{
"code": "490470",
"name": "세미파이브"
},
{
"code": "001300",
"name": "신한제17호스팩"
},
{
"code": "388210",
"name": "씨엠티엑스"
},
{
"code": "493280",
"name": "아이엠바이오로직스"
},
{
"code": "476830",
"name": "알지노믹스"
},
{
"code": "459550",
"name": "알트"
},
{
"code": "000110",
"name": "액스비스"
},
{
"code": "458350",
"name": "에스팀"
},
{
"code": "000090",
"name": "에임드바이오"
},
{
"code": "001050",
"name": "유진스팩12호"
},
{
"code": "469610",
"name": "이노테크"
},
{
"code": "261520",
"name": "이지스"
},
{
"code": "493330",
"name": "지에프아이"
},
{
"code": "000820",
"name": "카나프테라퓨틱스"
},
{
"code": "439960",
"name": "코스모로보틱스"
},
{
"code": "464490",
"name": "쿼드메디슨"
},
{
"code": "494120",
"name": "큐리오시스"
},
{
"code": "466690",
"name": "키움히어로제1호스팩"
},
{
"code": "001310",
"name": "키움히어로제2호스팩"
},
{
"code": "487580",
"name": "폴레드"
},
{
"code": "001010",
"name": "하나36호스팩"
},
{
"code": "408470",
"name": "한패스"
},
{
"code": "403810",
"name": "아이엘로보틱스"
},
{
"code": "059180",
"name": "엔더블유시"
},
{
"code": "950250",
"name": "테라뷰"
},
{
"code": "288180",
"name": "케이피항공산업"
}
]