빨라져라
This commit is contained in:
@@ -7,6 +7,7 @@ import Defines.EMBEDDING_PORT
|
||||
import Defines.LLM_PORT
|
||||
import network.TradingDecision
|
||||
import TradingLogStore
|
||||
import analyzer.TechnicalAnalyzer
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -33,7 +34,6 @@ import model.RankingStock
|
||||
import model.RankingType
|
||||
import model.UnifiedBalance
|
||||
import network.DartCodeManager
|
||||
import network.FinancialStatement
|
||||
import network.KisAuthService
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
@@ -133,7 +133,7 @@ object AutoTradingManager {
|
||||
((completeTradingDecision.safePossible() + append) * weights["safe"]!!)
|
||||
|
||||
if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
|
||||
var investmentGrade : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
|
||||
var investmentGrade = completeTradingDecision.investmentGrade ?: InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||
|
||||
val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
|
||||
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
|
||||
@@ -189,7 +189,7 @@ object AutoTradingManager {
|
||||
}
|
||||
}
|
||||
|
||||
val MIN_CONFIDENCE = 70.0 // 최소 신뢰도
|
||||
val MIN_CONFIDENCE = 60.0 // 최소 신뢰도
|
||||
var append = 0.0
|
||||
|
||||
fun getInvestmentGrade(
|
||||
@@ -197,44 +197,53 @@ object AutoTradingManager {
|
||||
totalScore: Double,
|
||||
confidence: Double
|
||||
): InvestmentGrade {
|
||||
// 1. 기본 조건 충족 여부
|
||||
if (totalScore < 68.0 || confidence < 70.0) {
|
||||
return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음)
|
||||
// [개선] 하드코딩된 60/70 대신 사용자 설정 최소 점수를 기준으로 사용
|
||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||
val minConfidence = minScore // 신뢰도 하한선도 매수 기준 점수와 동기화
|
||||
|
||||
// 1. 최소 기준 미달 시 (관망 대상)
|
||||
if (totalScore < (minScore * 0.8) || confidence < minConfidence) {
|
||||
return InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||
}
|
||||
|
||||
// 2. 단기/중기/장기 패턴 기준
|
||||
val ultraShort = ts.ultraShortScore
|
||||
val short = ts.shortTermScore
|
||||
val mid = ts.midTermScore
|
||||
val long = ts.longTermScore
|
||||
// 2. 패턴 점수 추출
|
||||
val shortAvg = (ts.ultraShortScore + ts.shortTermScore) / 2.0
|
||||
val midLongAvg = (ts.midTermScore + ts.longTermScore) / 2.0
|
||||
val isOverheated = ts.analyzer?.isOverheatedStock() ?: true
|
||||
|
||||
val shortAvg = listOf(ultraShort, short).average() // 초단기+단기
|
||||
val midLongAvg = listOf(mid, long).average() // 중기+장기
|
||||
// 3. [개선] 점수 구간을 5~10점씩 하향 조정하여 실제 '추천' 등급이 나오도록 보정
|
||||
val rawGrade = when {
|
||||
// [A그룹] 중장기 추세가 강한 상태
|
||||
midLongAvg >= 70.0 -> { // 75 -> 70 하향
|
||||
if (shortAvg >= 75.0) InvestmentGrade.LEVEL_5_STRONG_RECOMMEND // 80 -> 75
|
||||
else if (shortAvg >= 65.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 70 -> 65
|
||||
else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||
}
|
||||
|
||||
return when {
|
||||
// LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음
|
||||
shortAvg >= 85.0 && midLongAvg >= 80.0 ->
|
||||
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
|
||||
// [B그룹] 중장기 추세가 보통인 상태
|
||||
midLongAvg >= 60.0 -> { // 65 -> 60 하향
|
||||
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND // 75 -> 70
|
||||
else if (shortAvg >= 60.0) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND // 65 -> 60
|
||||
else InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||
}
|
||||
|
||||
// LEVEL_4: 중기·장기 기본 준수, 단기까지 양호
|
||||
midLongAvg >= 75.0 && shortAvg >= 70.0 ->
|
||||
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
||||
// [C그룹] 중장기는 약하지만 단기 에너지가 폭발적인 상태
|
||||
else -> {
|
||||
if (shortAvg >= 70.0) InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||
else InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||
}
|
||||
}
|
||||
|
||||
// LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형
|
||||
midLongAvg >= 70.0 && shortAvg in 60.0..70.0 ->
|
||||
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||
|
||||
// LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매
|
||||
shortAvg >= 75.0 && midLongAvg < 65.0 ->
|
||||
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||
|
||||
// LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함
|
||||
shortAvg >= 70.0 && midLongAvg < 55.0 ->
|
||||
InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||
|
||||
// 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립)
|
||||
else ->
|
||||
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||
// 4. 단기 과열 패널티 (일괄 1단계 강등)
|
||||
return if (isOverheated) {
|
||||
when (rawGrade) {
|
||||
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
||||
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||
else -> InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||
}
|
||||
} else {
|
||||
rawGrade
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,9 +393,7 @@ object AutoTradingManager {
|
||||
)
|
||||
} else {
|
||||
println("sellingAfterMarketOnePrice")
|
||||
// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} , 주문 가능 : ${holding.availOrderCount}, 수익율 : ${holding.profitRate}")
|
||||
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > 0.5) {
|
||||
// println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
|
||||
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
|
||||
var targetPrice = holding.currentPrice.toDouble()
|
||||
TradingLogStore.addAfterMarketLog(
|
||||
holding.name,
|
||||
@@ -394,30 +401,31 @@ object AutoTradingManager {
|
||||
"${if ("Y".equals(marketCode)) "시간외 단일가" else "대체거래소"} 시세로 ${holding.profitRate} 수익 예상"
|
||||
)
|
||||
|
||||
targetPrice = MarketUtil.roundToTickSize(targetPrice)
|
||||
// tradeService.postOrder(
|
||||
// stockCode = holding.code,
|
||||
// qty = holding.availOrderCount,
|
||||
// price = targetPrice.toInt().toString(),
|
||||
// isBuy = false,
|
||||
// orderDivision = if (marketCode.equals("Y")) "07" else "",
|
||||
// marketCode = if (marketCode.equals("Y")) "KRX" else "SOR"
|
||||
// ).onSuccess { newOrderNo ->
|
||||
// println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
|
||||
// TradingLogStore.addSellLog(
|
||||
// holding.code,
|
||||
// targetPrice.toString(),
|
||||
// "SELL",
|
||||
// "🎊 시간외 단일가 주식 재고털이 주문 완료"
|
||||
// )
|
||||
// }.onFailure {
|
||||
// TradingLogStore.addSellLog(
|
||||
// holding.code,
|
||||
// targetPrice.toString(),
|
||||
// "SELL",
|
||||
// "🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
|
||||
// )
|
||||
// }
|
||||
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
|
||||
|
||||
tradeService.postOrder(
|
||||
stockCode = holding.code,
|
||||
qty = holding.availOrderCount,
|
||||
price = targetPrice.toInt().toString(),
|
||||
isBuy = false,
|
||||
orderDivision = if (marketCode.equals("Y")) "07" else "",
|
||||
marketCode = if (marketCode.equals("Y")) "KRX" else "NXT"
|
||||
).onSuccess { newOrderNo ->
|
||||
println("✅ [재주문 완료] ${holding.name}: $newOrderNo")
|
||||
TradingLogStore.addSellLog(
|
||||
holding.code,
|
||||
targetPrice.toString(),
|
||||
"SELL",
|
||||
"🎊 시간외 단일가 주식 재고털이 주문 완료"
|
||||
)
|
||||
}.onFailure {
|
||||
TradingLogStore.addSellLog(
|
||||
holding.code,
|
||||
targetPrice.toString(),
|
||||
"SELL",
|
||||
"🎊 시간외 단일가 주식 재고털이 주문 실패[${it.message}] "
|
||||
)
|
||||
}
|
||||
}
|
||||
delay(300) // API 호출 부하 방지
|
||||
}
|
||||
@@ -448,12 +456,12 @@ object AutoTradingManager {
|
||||
val now = LocalTime.now()
|
||||
val currentMinute = now.minute
|
||||
var isBefore930 = false
|
||||
// if (now.hour == 9 && currentMinute < 30) {
|
||||
// targetPrice = targetPrice
|
||||
// isBefore930 = true
|
||||
// } else {
|
||||
if (now.hour == 9 && currentMinute < 30) {
|
||||
targetPrice = targetPrice
|
||||
isBefore930 = true
|
||||
} else {
|
||||
targetPrice = MarketUtil.roundToTickSize(targetPrice + MarketUtil.getTickSize(targetPrice))
|
||||
// }
|
||||
}
|
||||
println("🔄 [재주문] ${holding.name} (${holding.code}) 매도 목표 ${targetPrice} 미체결 매도 건 재주문 시도")
|
||||
tradeService.postOrder(
|
||||
stockCode = holding.code,
|
||||
@@ -540,12 +548,6 @@ object AutoTradingManager {
|
||||
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
|
||||
while (isActive) {
|
||||
try {
|
||||
// listOf<String>("Y","X").forEach { code ->
|
||||
// KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
|
||||
// sellingAfterMarketOnePrice(KisTradeService, it, code)
|
||||
// }
|
||||
// delay(1000)
|
||||
// }
|
||||
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
|
||||
@@ -639,20 +641,6 @@ object AutoTradingManager {
|
||||
if (AUTOSELL) balance?.let { resumePendingSellOrders(KisTradeService, it) }
|
||||
return balance
|
||||
} else {
|
||||
// val now = LocalTime.now()
|
||||
// val currentMinute = now.minute
|
||||
// if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) {
|
||||
// if (lastForceCheckMinute != currentMinute) {
|
||||
// listOf<String>("Y","X").forEach { code ->
|
||||
// KisTradeService.fetchIntegratedBalance(code).getOrNull()?.let {
|
||||
// sellingAfterMarketOnePrice(KisTradeService, it, code)
|
||||
// }
|
||||
// delay(1000)
|
||||
// }
|
||||
// lastForceCheckMinute = currentMinute // 실행 완료 기록
|
||||
// }
|
||||
// }
|
||||
//
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -705,7 +693,7 @@ object AutoTradingManager {
|
||||
while (iterator.hasNext()) {
|
||||
totalCount--
|
||||
val stock = iterator.next()
|
||||
if (now.isBefore(H16) && now.isAfter(H08M35)) {
|
||||
// if (now.isBefore(H16) && now.isAfter(H08M35)) {
|
||||
if (BLACKLISTEDSTOCKCODES.contains(stock.code)) {
|
||||
println("❌ 차단 처리된 주식 : ${stock.name}")
|
||||
} else {
|
||||
@@ -719,7 +707,7 @@ object AutoTradingManager {
|
||||
println("남은 후보군 개수 : ${totalCount}")
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
// }
|
||||
sellSchedule()
|
||||
}
|
||||
println("⏱️ [Cycle End] ${LocalTime.now()}")
|
||||
@@ -738,7 +726,7 @@ object AutoTradingManager {
|
||||
lastForceCheckMinute = currentMinute // 실행 완료 기록
|
||||
}
|
||||
}
|
||||
else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3 || currentMinute % 10 == 9)) {
|
||||
else if((now.hour == 16 || now.hour == 17) && (currentMinute % 10 == 3)) {
|
||||
if (lastForceCheckMinute != currentMinute) {
|
||||
TradingLogStore.addAnalyzer(
|
||||
" - ",
|
||||
@@ -959,11 +947,6 @@ object AutoTradingManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun addStock(currentPrice : Double , technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) {
|
||||
scope.launch {
|
||||
RagService.processStock(currentPrice,technicalAnalyzer,stockName, stockCode, result)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAndRestart() {
|
||||
if (!isRunning()) {
|
||||
@@ -976,542 +959,6 @@ object AutoTradingManager {
|
||||
|
||||
}
|
||||
|
||||
object FinancialAnalyzer {
|
||||
|
||||
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
|
||||
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
|
||||
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
|
||||
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
|
||||
val isNotCrashing = fs.netIncomeGrowth > -40.0
|
||||
return isDebtSafe && isLiquiditySafe && isNotDeficit && isNotCrashing
|
||||
}
|
||||
|
||||
/**
|
||||
* [매수 고려] 우량 기업 요건 확인
|
||||
* 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다.
|
||||
*/
|
||||
fun isBuyConsiderationMet(fs: FinancialStatement): Boolean {
|
||||
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
|
||||
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
|
||||
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
|
||||
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
|
||||
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
|
||||
|
||||
return highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy
|
||||
}
|
||||
|
||||
|
||||
fun toString(fs : FinancialStatement): String {
|
||||
var buffer = StringBuffer()
|
||||
val isDebtSafe = fs.debtRatio < 200.0 // 부채비율 200% 미만
|
||||
val isLiquiditySafe = fs.quickRatio > 80.0 // 당좌비율 80% 이상
|
||||
val isNotDeficit = fs.isNetIncomePositive // 당기순이익은 일단 흑자여야 함
|
||||
val isNotCrashing = fs.netIncomeGrowth > -40.0
|
||||
if ((isDebtSafe && isLiquiditySafe && isNotDeficit) == false) {
|
||||
if (!isDebtSafe)buffer.appendLine( "부채비율 200% 이상")
|
||||
if (!isLiquiditySafe)buffer.appendLine( "당좌비율 80% 미만")
|
||||
if (!isNotDeficit)buffer.appendLine( "당기순이익 적자")
|
||||
if (!isNotCrashing) { buffer.appendLine("당기순이익 급감(${String.format("%.1f", fs.netIncomeGrowth)}%)") }
|
||||
buffer.appendLine("최소 기준 미달")
|
||||
} else {
|
||||
buffer.appendLine("최소 기준 충족")
|
||||
}
|
||||
|
||||
val highProfitability = fs.roe >= 10.0 // ROE 10% 이상
|
||||
val strongGrowth = fs.netIncomeGrowth >= 15.0 // 이익 성장률 15% 이상
|
||||
val verySafeDebt = fs.debtRatio <= 100.0 // 부채비율 100% 이하 (안전)
|
||||
val goodLiquidity = fs.quickRatio >= 120.0 // 당좌비율 120% 이상 (여유)
|
||||
val businessHealthy = fs.isOperatingProfitPositive // 본업(영업이익)이 흑자
|
||||
|
||||
if ((highProfitability && strongGrowth && verySafeDebt && goodLiquidity && businessHealthy) == false) {
|
||||
if(!highProfitability) buffer.appendLine( "ROE 10% 미만")
|
||||
if(!strongGrowth) buffer.appendLine( "이익 성장률 15% 미만")
|
||||
if(!verySafeDebt) buffer.appendLine( "부채비율 100% 이상 (안전성 미달)")
|
||||
if(!goodLiquidity) buffer.appendLine( "당좌비율 120% 이하 (여유 없음)")
|
||||
if(!businessHealthy) buffer.appendLine( "본업(영업이익)이 적자")
|
||||
buffer.appendLine("재무 건전성 및 성장성 미달")
|
||||
} else {
|
||||
buffer.appendLine("재무 건전성 및 성장성 충족")
|
||||
}
|
||||
|
||||
return buffer.toString()
|
||||
}
|
||||
/**
|
||||
* 종합 상태 반환 (UI 또는 로그용)
|
||||
*/
|
||||
fun getInvestmentStatus(fs: FinancialStatement): String {
|
||||
return when {
|
||||
isBuyConsiderationMet(fs) -> "🚀 [매수 검토 권장] 재무 건전성 및 성장성 우수"
|
||||
isSafetyBeltMet(fs) -> "⚖️ [관망/보류] 생존 요건은 충족하나 성장성 부족"
|
||||
else -> "🚨 [위험/제외] 재무 안정성 미달 또는 적자 기업"
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateScore(fs: FinancialStatement): Int {
|
||||
var score = 50.0 // 기본 점수
|
||||
|
||||
// 성장성 (영업이익 증가율)
|
||||
score += when {
|
||||
fs.operatingProfitGrowth > 20 -> 20
|
||||
fs.operatingProfitGrowth > 0 -> 10
|
||||
else -> -10 // 역성장 시 감점
|
||||
}
|
||||
|
||||
// 수익성 (ROE)
|
||||
score += when {
|
||||
fs.roe > 15 -> 15
|
||||
fs.roe > 5 -> 5
|
||||
fs.roe < 0 -> -15 // 적자 시 큰 감점
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// 안정성 (부채비율)
|
||||
score += when {
|
||||
fs.debtRatio < 100 -> 15
|
||||
fs.debtRatio < 200 -> 5
|
||||
else -> -10
|
||||
}
|
||||
|
||||
// 유동성 (당좌비율)
|
||||
if (fs.quickRatio < 100) score -= 10 // 단기 채무 지급 능력 부족 시 감점
|
||||
|
||||
return score.coerceIn(0.0, 100.0).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
data class InvestmentScores(
|
||||
val ultraShort: Int, // 초단기 (분봉/에너지)
|
||||
val shortTerm: Int, // 단기 (일봉/뉴스)
|
||||
val midTerm: Int, // 중기 (주봉/재무)
|
||||
val longTerm: Int // 장기 (월봉/펀더멘털)
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return """
|
||||
평점 : ${avg()}
|
||||
초단 : $ultraShort
|
||||
단기 : $shortTerm
|
||||
중기 : $midTerm
|
||||
장기 : $longTerm
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
fun avg() = listOf(ultraShort, shortTerm, midTerm, longTerm).average()
|
||||
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class TechnicalAnalyzer {
|
||||
var monthly: List<CandleData> = emptyList()
|
||||
var weekly: List<CandleData> = emptyList()
|
||||
var daily: List<CandleData> = emptyList()
|
||||
var min30: List<CandleData> = emptyList()
|
||||
|
||||
fun isValid() = listOf(min30,monthly, weekly,daily).filter { it.size > 0 }.size == 4
|
||||
|
||||
fun isOverheatedStock(): Boolean {
|
||||
if (min30.size < 20 || daily.size < 20) return false
|
||||
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
|
||||
// 1. 일봉 기준 이격도 체크 (20일 이평선 대비)
|
||||
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||||
// 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열)
|
||||
if (disparityDaily > 125.0) return true
|
||||
|
||||
// 2. 분봉(30분봉) 기준 단기 급등 체크
|
||||
val startPrice30 = min30.first().stck_oprc.toDouble()
|
||||
val riseRate30 = ((currentPrice - startPrice30) / startPrice30) * 100
|
||||
// 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험
|
||||
if (riseRate30 > 15.0) return true
|
||||
|
||||
// 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑)
|
||||
val avgVol = min30.dropLast(3).map { it.cntg_vol.toDouble() }.average()
|
||||
val recentVol = min30.last().cntg_vol.toDouble()
|
||||
// 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음
|
||||
if (recentVol > avgVol * 10) return true
|
||||
|
||||
// 4. 볼린저 밴드 상단 이탈 강도
|
||||
// ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인
|
||||
return false
|
||||
}
|
||||
|
||||
fun calculateScores(
|
||||
financialScore: Int // 재무제표 점수 (성장률 등 기반)
|
||||
): InvestmentScores {
|
||||
|
||||
// 1. 초단기 (분봉 + 에너지 지표 위주)
|
||||
var ultra = (calculateMFI(min30, 14) * 0.4 +
|
||||
calculateStochastic(min30) * 0.3 +
|
||||
(if(calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt()
|
||||
|
||||
// 2. 단기 (일봉 추세 + OBV 에너지)
|
||||
var short = (calculateRSI(daily) * 0.3 +
|
||||
(if(calculateOBV(daily) > 0) 40 else 10) +
|
||||
(if(calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt()
|
||||
|
||||
// 3. 중기 (주봉 + 재무 점수 혼합)
|
||||
var mid = (if(calculateChange(weekly) > 0) 40 else 10) +
|
||||
(financialScore * 0.6).toInt()
|
||||
|
||||
// 4. 장기 (월봉 + 섹터/기업 펀더멘털)
|
||||
var long = (if(calculateChange(monthly) > 0) 50 else 0) +
|
||||
(financialScore * 0.5).toInt()
|
||||
|
||||
// 1. 일봉 이격도 과열 체크 (20일 이평선 기준)
|
||||
if (daily.size >= 20) {
|
||||
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||||
|
||||
if (disparityDaily > 115.0) { // 20일선보다 15% 이상 떠 있으면 감점 시작
|
||||
val penalty = ((disparityDaily - 115.0) * 0.3).toInt() // 초과분 1%당 2점 감점
|
||||
short -= penalty
|
||||
ultra -= (penalty / 2) // 초단기에도 영향
|
||||
println("⚠️ [과열 감점] 일봉 이격도(${String.format("%.1f", disparityDaily)}%): -${penalty}점")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 주봉 급등 체크 (최근 3주간의 상승폭)
|
||||
if (weekly.size >= 3) {
|
||||
val weeklyChange = calculateChange(weekly.takeLast(3))
|
||||
if (weeklyChange > 30.0) { // 3주간 30% 이상 급등 시
|
||||
mid -= 6
|
||||
short -= 3
|
||||
println("⚠️ [과열 감점] 주봉 급등(${String.format("%.1f", weeklyChange)}%): -10점")
|
||||
}
|
||||
}
|
||||
|
||||
return InvestmentScores(
|
||||
ultraShort = ultra.coerceIn(0, 100),
|
||||
shortTerm = short.coerceIn(0, 100),
|
||||
midTerm = mid.coerceIn(0, 100),
|
||||
longTerm = long.coerceIn(0, 100)
|
||||
)
|
||||
}
|
||||
|
||||
fun generateComprehensiveReport(): String {
|
||||
// [1] 단기 에너지 지표 계산 (최근 30분봉 기준)
|
||||
val obv = calculateOBV(min30)
|
||||
val mfi = calculateMFI(min30, 14)
|
||||
val adLine = calculateADLine(min30)
|
||||
|
||||
// [2] 시계열별 가격 변동 및 추세 요약
|
||||
val m10 = min30.takeLast(10)
|
||||
val change10 = calculateChange(m10)
|
||||
val change30 = calculateChange(min30)
|
||||
val changeDaily = calculateChange(daily.takeLast(2)) // 전일 대비
|
||||
|
||||
// [3] 이평선 및 가격 위치
|
||||
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish())
|
||||
// [4] 거래량 강도
|
||||
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
|
||||
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
|
||||
val volStrength = if (avgVol30 > 0) recentVol5 / avgVol30 else 1.0
|
||||
val atr = calculateATR(min30)
|
||||
val stochK = calculateStochastic(min30)
|
||||
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
|
||||
return """
|
||||
- 초/단타 종합 스코어: ${signal.compositeScore} / 100
|
||||
- 초/단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
|
||||
- 초/단타 성공 확률 예측: ${signal.successProbPct}%
|
||||
- 초/단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
|
||||
- 초/단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배
|
||||
- 초/단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원)
|
||||
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
|
||||
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
|
||||
- 30분 대비: ${ "%.2f".format(change30) }% 변동
|
||||
- 10분 대비: ${ "%.2f".format(change10) }% 변동
|
||||
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
|
||||
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) }
|
||||
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) }
|
||||
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) }
|
||||
- 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준
|
||||
- ATR (평균 변동폭): ${"%.0f".format(atr)}원
|
||||
- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원
|
||||
- 스토캐스틱(%K): ${"%.1f".format(stochK)}
|
||||
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준
|
||||
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
|
||||
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
|
||||
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* ATR (Average True Range): 최근 변동 폭의 평균. 그래프의 '출렁임' 크기를 측정
|
||||
*/
|
||||
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
|
||||
val sub = candles.takeLast(period + 1)
|
||||
val trList = mutableListOf<Double>()
|
||||
for (i in 1 until sub.size) {
|
||||
val high = sub[i].stck_hgpr.toDouble()
|
||||
val low = sub[i].stck_lwpr.toDouble()
|
||||
val prevClose = sub[i - 1].stck_prpr.toDouble()
|
||||
|
||||
val tr = maxOf(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose))
|
||||
trList.add(tr)
|
||||
}
|
||||
return trList.average()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stochastic (%K): 최근 가격 범위 내에서 현재가의 위치 (0~100)
|
||||
* 반복되는 파동(Ups and Downs)에서 현재가 고점인지 저점인지 판단
|
||||
*/
|
||||
fun calculateStochastic(candles: List<CandleData>, period: Int = 14): Double {
|
||||
val sub = candles.takeLast(period)
|
||||
val highest = sub.maxOf { it.stck_hgpr.toDouble() }
|
||||
val lowest = sub.minOf { it.stck_lwpr.toDouble() }
|
||||
val current = sub.last().stck_prpr.toDouble()
|
||||
|
||||
return if (highest != lowest) (current - lowest) / (highest - lowest) * 100 else 50.0
|
||||
}
|
||||
|
||||
private fun calculateChange(list: List<CandleData>): Double {
|
||||
val start = list.first().stck_oprc.toDouble()
|
||||
val end = list.last().stck_prpr.toDouble()
|
||||
return if (start != 0.0) ((end - start) / start) * 100 else 0.0
|
||||
}
|
||||
|
||||
private fun calculateRSI(list: List<CandleData>): Double {
|
||||
if (list.size < 2) return 50.0
|
||||
var gains = 0.0
|
||||
var losses = 0.0
|
||||
for (i in 1 until list.size) {
|
||||
val diff = list[i].stck_prpr.toDouble() - list[i - 1].stck_prpr.toDouble()
|
||||
if (diff > 0) gains += diff else losses -= diff
|
||||
}
|
||||
return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100
|
||||
}
|
||||
|
||||
fun isDailyBullish(): Boolean {
|
||||
if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
|
||||
|
||||
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||
|
||||
// 1. MA20 (한 달 생명선) 계산
|
||||
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
|
||||
// 2. MA5 (단기 가속도) 계산
|
||||
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
|
||||
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
|
||||
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val isMa5Rising = ma5 > prevMa5
|
||||
|
||||
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
|
||||
return currentPrice > ma20 && isMa5Rising
|
||||
}
|
||||
|
||||
fun calculateOBV(candles: List<CandleData>): Double {
|
||||
var obv = 0.0
|
||||
for (i in 1 until candles.size) {
|
||||
val prevClose = candles[i - 1].stck_prpr.toDouble()
|
||||
val currClose = candles[i].stck_prpr.toDouble()
|
||||
val currVol = candles[i].cntg_vol.toDouble()
|
||||
|
||||
when {
|
||||
currClose > prevClose -> obv += currVol
|
||||
currClose < prevClose -> obv -= currVol
|
||||
}
|
||||
}
|
||||
return obv
|
||||
}
|
||||
|
||||
/**
|
||||
* MFI (Money Flow Index) 계산 (기간: 보통 14일)
|
||||
*/
|
||||
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
|
||||
val subList = candles.takeLast(period + 1)
|
||||
var posFlow = 0.0
|
||||
var negFlow = 0.0
|
||||
|
||||
for (i in 1 until subList.size) {
|
||||
val prevTypical = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3
|
||||
val currTypical = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3
|
||||
val moneyFlow = currTypical * subList[i].cntg_vol.toDouble()
|
||||
|
||||
if (currTypical > prevTypical) posFlow += moneyFlow
|
||||
else if (currTypical < prevTypical) negFlow += moneyFlow
|
||||
}
|
||||
|
||||
return if (negFlow == 0.0) 100.0 else 100 - (100 / (1+ (posFlow / negFlow)))
|
||||
}
|
||||
|
||||
private fun calculateADLine(candles: List<CandleData>): Double {
|
||||
var ad = 0.0
|
||||
candles.forEach {
|
||||
val high = it.stck_hgpr.toDouble(); val low = it.stck_lwpr.toDouble(); val close = it.stck_prpr.toDouble()
|
||||
val mfv = if (high != low) ((close - low) - (high - close)) / (high - low) else 0.0
|
||||
ad += mfv * it.cntg_vol.toDouble()
|
||||
}
|
||||
return ad
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
monthly = emptyList()
|
||||
weekly = emptyList()
|
||||
daily = emptyList()
|
||||
min30 = emptyList()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class ScalpingAnalyzer {
|
||||
companion object {
|
||||
private const val SMA_SHORT = 10
|
||||
private const val SMA_LONG = 20
|
||||
private const val RSI_WINDOW = 14
|
||||
private const val VOL_WINDOW = 20
|
||||
private const val VOL_SURGE_THRESHOLD = 1.5
|
||||
private const val RSI_THRESHOLD = 50.0
|
||||
private const val BB_LOWER_POS = 0.2
|
||||
private const val BB_UPPER_POS = 0.8
|
||||
private const val ATR_WINDOW = 14
|
||||
private const val DEFAULT_SL_PCT = -1.5
|
||||
private const val DEFAULT_TP_PCT = 1.5
|
||||
private const val HIGH_SCORE_THRESHOLD = 80
|
||||
}
|
||||
|
||||
fun computeRSI(closes: List<Double>, window: Int = RSI_WINDOW): List<Double> {
|
||||
val rsi = mutableListOf<Double>()
|
||||
if (closes.size < window + 1) return rsi
|
||||
for (i in window until closes.size) {
|
||||
val gains = mutableListOf<Double>()
|
||||
val losses = mutableListOf<Double>()
|
||||
for (j in (i - window + 1) until i + 1) {
|
||||
val delta = closes[j] - closes[j - 1]
|
||||
if (delta > 0) gains.add(delta) else losses.add(abs(delta))
|
||||
}
|
||||
val avgGain = gains.average()
|
||||
val avgLoss = losses.average()
|
||||
val rs = if (avgLoss > 0) avgGain / avgLoss else Double.POSITIVE_INFINITY
|
||||
rsi.add(100.0 - (100.0 / (1.0 + rs)))
|
||||
}
|
||||
return rsi
|
||||
}
|
||||
|
||||
fun bollingerBands(closes: List<Double>, window: Int = SMA_LONG): Triple<List<Double>, List<Double>, List<Double>> {
|
||||
val sma = mutableListOf<Double>()
|
||||
val upper = mutableListOf<Double>()
|
||||
val lower = mutableListOf<Double>()
|
||||
for (i in window - 1 until closes.size) {
|
||||
val slice = closes.subList(i - window + 1, i + 1)
|
||||
val mean = slice.average()
|
||||
val std = sqrt(slice.map { (it - mean).pow(2.0) }.average()) * 2.0
|
||||
sma.add(mean)
|
||||
upper.add(mean + std)
|
||||
lower.add(mean - std)
|
||||
}
|
||||
return Triple(upper, sma, lower)
|
||||
}
|
||||
|
||||
fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
|
||||
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
|
||||
|
||||
val closes = candles.map { it.close }
|
||||
val volumes = candles.map { it.volume }
|
||||
|
||||
// 지표 계산
|
||||
val sma10 = simpleMovingAverage(closes, SMA_SHORT)
|
||||
val sma20 = simpleMovingAverage(closes, SMA_LONG)
|
||||
val rsiList = computeRSI(closes)
|
||||
val volAvg = simpleMovingAverage(volumes, VOL_WINDOW)
|
||||
val volRatioList = volumes.mapIndexed { i, v -> if (i >= VOL_WINDOW) v / volAvg[i - VOL_WINDOW] else 0.0 }
|
||||
val (bbUpper, bbMiddle, bbLower) = bollingerBands(closes)
|
||||
|
||||
val current = candles.last()
|
||||
val idx = candles.size - 1
|
||||
val currentClose = current.close
|
||||
val sma10Now = if (sma10.size > 0) sma10.last() else 0.0
|
||||
val sma20Now = if (sma20.size > 0) sma20.last() else 0.0
|
||||
val rsiNow = if (rsiList.isNotEmpty()) rsiList.last() else 0.0
|
||||
val volRatioNow = volRatioList.last()
|
||||
val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) {
|
||||
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
|
||||
} else 0.5
|
||||
|
||||
|
||||
|
||||
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
|
||||
val isBreakout = currentClose > nearHigh
|
||||
|
||||
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
|
||||
val bodySize = abs(current.close - current.open)
|
||||
val lowerShadow = minOf(current.close, current.open) - current.low
|
||||
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
|
||||
|
||||
// 신호 조건 고도화
|
||||
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
|
||||
|
||||
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
val rsiBull = rsiNow > RSI_THRESHOLD
|
||||
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD
|
||||
val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
// val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
|
||||
val ma5Daily = if (candles.size >= 5) candles.takeLast(5).map { it.close.toDouble() }.average() else currentClose
|
||||
val dailyDisparity = (currentClose / ma5Daily) * 100
|
||||
|
||||
// 과열 기준 정의
|
||||
val isOverheated = dailyDisparity > 110.0 // 일봉 5일선 대비 10% 이상 이격 시 과열로 간주
|
||||
|
||||
// 매수 신호 조건에 과열 방지 추가
|
||||
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout && !isOverheated
|
||||
|
||||
|
||||
val score = (if (maBull) 25 else 0) +
|
||||
(if (rsiBull) 15 else 0) +
|
||||
(if (isBreakout) 20 else 0) + // 돌파 에너지 가중치
|
||||
(minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() +
|
||||
(if (bbGood) 10 else 0) +
|
||||
(if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수
|
||||
|
||||
// 위험도 (ATR proxy)
|
||||
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
|
||||
val atrProxy = if (returns.size >= ATR_WINDOW) {
|
||||
returns.subList(returns.size - ATR_WINDOW, returns.size).average()
|
||||
} else 1.0
|
||||
val riskLevel = when {
|
||||
abs(atrProxy) < 1 -> "Low"
|
||||
abs(atrProxy) < 2 -> "Medium"
|
||||
else -> "High"
|
||||
}
|
||||
|
||||
// 성공 확률 & SL/TP
|
||||
val successProb = if (buySignal) 75.0 else 35.0 + (score / 100.0 * 20)
|
||||
val slPrice = currentClose * (1 + DEFAULT_SL_PCT / 100)
|
||||
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
|
||||
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
|
||||
|
||||
|
||||
|
||||
return ScalpingSignalModel(
|
||||
currentPrice = currentClose,
|
||||
buySignal = buySignal,
|
||||
compositeScore = minOf(score.toInt(), 100),
|
||||
successProbPct = successProb,
|
||||
riskLevel = riskLevel,
|
||||
rsi = rsiNow,
|
||||
volRatio = volRatioNow,
|
||||
suggestedSlPrice = slPrice,
|
||||
suggestedTpPrice = tpPrice,
|
||||
riskRewardRatio = rrRatio
|
||||
)
|
||||
}
|
||||
|
||||
private fun simpleMovingAverage(values: List<Double>, window: Int): List<Double> {
|
||||
val sma = mutableListOf<Double>()
|
||||
for (i in window - 1 until values.size) {
|
||||
val slice = values.subList(i - window + 1, i + 1)
|
||||
sma.add(slice.average())
|
||||
}
|
||||
return sma
|
||||
}
|
||||
}
|
||||
|
||||
data class Candle(
|
||||
val timestamp: Long,
|
||||
@@ -1522,51 +969,6 @@ data class Candle(
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
data class ScalpingSignalModel(
|
||||
val currentPrice: Double,
|
||||
val buySignal: Boolean,
|
||||
val compositeScore: Int, // 0-100: 종합 매수 추천도 (80+ 강매수)
|
||||
val successProbPct: Double, // 성공 확률 추정 %
|
||||
val riskLevel: String, // "Low", "Medium", "High"
|
||||
val rsi: Double,
|
||||
val volRatio: Double,
|
||||
val suggestedSlPrice: Double, // 손절 가격
|
||||
val suggestedTpPrice: Double, // 익절 가격
|
||||
val riskRewardRatio: Double
|
||||
)
|
||||
|
||||
fun CandleData.toScalpingCandle(): Candle {
|
||||
// 1. 날짜(YYYYMMDD)와 시간(HHMMSS) 문자열 결합
|
||||
val dateTimeStr = "${this.stck_bsop_date}${this.stck_cntg_hour}"
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
|
||||
|
||||
// 2. 타임스탬프(Epoch Milliseconds) 계산
|
||||
val timestamp = try {
|
||||
val ldt = LocalDateTime.parse(dateTimeStr, formatter)
|
||||
ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
} catch (e: Exception) {
|
||||
// 시간 파싱 실패 시 현재 시스템 시간 사용
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// 3. String 필드들을 Double로 변환하여 Candle 객체 생성
|
||||
return Candle(
|
||||
timestamp = timestamp,
|
||||
open = this.stck_oprc.toDoubleOrNull() ?: 0.0,
|
||||
high = this.stck_hgpr.toDoubleOrNull() ?: 0.0,
|
||||
low = this.stck_lwpr.toDoubleOrNull() ?: 0.0,
|
||||
close = this.stck_prpr.toDoubleOrNull() ?: 0.0, // stck_prpr가 종가 역할
|
||||
volume = this.cntg_vol.toDoubleOrNull() ?: 0.0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 전체를 변환하는 유틸리티
|
||||
*/
|
||||
fun List<CandleData>.toScalpingList(): List<Candle> {
|
||||
return this.map { it.toScalpingCandle() }
|
||||
}
|
||||
|
||||
|
||||
enum class InvestmentGrade(
|
||||
val displayName: String,
|
||||
|
||||
Reference in New Issue
Block a user