빨라져라
This commit is contained in:
parent
a700d54dfe
commit
9172cca791
@ -61,7 +61,6 @@ import service.AutoTradingManager
|
||||
import service.AutoTradingManager.isSystemCleanedUpToday
|
||||
import service.SystemSleepPreventer
|
||||
import service.TradingDecisionCallback
|
||||
import ui.DashboardScreen
|
||||
import ui.SettingsScreen
|
||||
import ui.TradingDecisionLog
|
||||
import util.PortFinder
|
||||
@ -262,7 +261,7 @@ fun main() = application {
|
||||
)
|
||||
}
|
||||
AppScreen.Dashboard -> {
|
||||
DashboardScreen()
|
||||
// DashboardScreen()
|
||||
}
|
||||
AppScreen.TradingDecision -> {
|
||||
TradingDecisionLog()
|
||||
|
||||
206
src/main/kotlin/analyzer/FinancialAnalyzer.kt
Normal file
206
src/main/kotlin/analyzer/FinancialAnalyzer.kt
Normal file
@ -0,0 +1,206 @@
|
||||
package analyzer
|
||||
|
||||
import kotlin.compareTo
|
||||
|
||||
|
||||
object FinancialAnalyzer {
|
||||
|
||||
/**
|
||||
* [매수 고려] 우량 기업 요건 확인
|
||||
* 모든 조건 충족 시 적극적인 분석(AI/차트) 단계로 진입합니다.
|
||||
*/
|
||||
fun isSafetyBeltMet(fs: FinancialStatement): Boolean {
|
||||
// 1. 유동성 위기 체크 (이건 유지하는 것이 좋습니다)
|
||||
val isDebtSafe = fs.debtRatio < 300.0 // 200% -> 300%로 완화
|
||||
val isLiquiditySafe = fs.quickRatio > 60.0 // 80% -> 60%로 완화 (급전이 필요한 수준만 차단)
|
||||
|
||||
// 2. 턴어라운드 허용 (적자여도 개선 중이면 통과)
|
||||
val isTurningAround = !fs.isNetIncomePositive && fs.operatingProfitGrowth > 50.0
|
||||
val isNotFatalDeficit = fs.isNetIncomePositive || isTurningAround
|
||||
|
||||
// 3. 상장폐지 요건 중심 필터
|
||||
val isNotCapitalImpaired = fs.capitalImpairmentRate < 50.0 // 자본잠식 50% 이상 차단
|
||||
val isNotLossExploding = fs.lossToSalesRatio < 150.0 // 매출보다 손실이 너무 크면 차단
|
||||
|
||||
// 최종: 정말 위험한 경우가 아니면 분석 단계(AI/뉴스)로 보냄
|
||||
return isDebtSafe && isLiquiditySafe && isNotFatalDeficit && isNotCapitalImpaired
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun calculateScore(fs: FinancialStatement): Int {
|
||||
var score = 50.0 // 중립 시작
|
||||
|
||||
// 1. 수익성 및 성장 추세 (Max 50)
|
||||
score += when {
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 20.0 -> 30.0 // 우량 성장
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -30.0 -> -20.0 // 쇠퇴 위험
|
||||
!fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 50.0 -> 20.0 // 턴어라운드 신호
|
||||
!fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -20.0 -> -30.0 // 적자 심화
|
||||
else -> 0.0
|
||||
}
|
||||
|
||||
// 2. 수익 효율 ROE (Max 30)
|
||||
score += (fs.roe.coerceIn(-20.0, 20.0) * 1.5)
|
||||
|
||||
// 3. 안정성 (Max 20)
|
||||
if (fs.debtRatio <= 100.0) score += 20.0
|
||||
else if (fs.debtRatio <= 150.0) score += 10.0
|
||||
|
||||
// 4. 감점 페널티 (위험 징후 시 최대 -50)
|
||||
if (fs.capitalImpairmentRate > 20.0) score -= 30.0
|
||||
if (fs.debtAccelerationRate > 100.0) score -= 20.0
|
||||
|
||||
return score.coerceIn(0.0, 100.0).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 상황별 상태 메시지 정교화
|
||||
*/
|
||||
fun getInvestmentStatus(fs: FinancialStatement): String {
|
||||
return when {
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 10.0 -> "🚀 [성장중] 실적 개선세 뚜렷"
|
||||
!fs.isOperatingProfitPositive && fs.operatingProfitGrowth > 40.0 -> "☀️ [회복중] 적자폭 급감, 턴어라운드 가시화"
|
||||
fs.isOperatingProfitPositive && fs.operatingProfitGrowth < -40.0 -> "⚠️ [쇠퇴중] 이익 급감, 적자전환 유의"
|
||||
else -> "🚨 [부실] 재무 구조 악화 지속"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object FinancialAnalyzer2 {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
75
src/main/kotlin/analyzer/FinancialMapper.kt
Normal file
75
src/main/kotlin/analyzer/FinancialMapper.kt
Normal file
@ -0,0 +1,75 @@
|
||||
package analyzer
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.Double
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FinancialStatement(
|
||||
val revenueGrowth: Double = 0.0, // 매출액 증가율
|
||||
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
|
||||
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
|
||||
val roe: Double = 0.0, // ROE
|
||||
val debtRatio: Double = 0.0, // 부채비율
|
||||
val quickRatio: Double = 0.0, // 당좌비율
|
||||
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
|
||||
val isNetIncomePositive: Boolean = false,
|
||||
val capitalImpairmentRate: Double = 0.0,
|
||||
val debtAccelerationRate: Double = 0.0,
|
||||
val lossToSalesRatio: Double = 0.0
|
||||
)
|
||||
|
||||
object FinancialMapper {
|
||||
fun mapRawTextToStatement(rawText: String): FinancialStatement {
|
||||
if (rawText.isBlank()) return FinancialStatement()
|
||||
|
||||
val current = extractYearlyValues(rawText, "당기")
|
||||
val previous = extractYearlyValues(rawText, "전기")
|
||||
|
||||
// 기본 수치 추출
|
||||
val opCurrent = current["영업이익"] ?: 0.0
|
||||
val opPrevious = previous["영업이익"] ?: 0.0
|
||||
val salesCurrent = current["매출액"] ?: 1.0
|
||||
val niCurrent = current["당기순이익(손실)"] ?: 0.0
|
||||
val equityCurrent = current["자본총계"] ?: 1.0
|
||||
val capitalStock = current["자본금"] ?: 1.0
|
||||
val debtCurrent = current["부채총계"] ?: 0.0
|
||||
val debtPrevious = previous["부채총계"] ?: 1.0
|
||||
val currentAssets = current["유동자산"] ?: 0.0
|
||||
val currentLiabilities = current["유동부채"] ?: 1.0
|
||||
|
||||
// [강화] 자본잠식률: (자본금 - 자본총계) / 자본금
|
||||
val capitalImpairment = (capitalStock - equityCurrent) / capitalStock * 100
|
||||
|
||||
// [강화] 부채 가속도: 전년 대비 부채 증가율
|
||||
val debtAcceleration = ((debtCurrent - debtPrevious) / debtPrevious) * 100
|
||||
|
||||
// [강화] 매출 대비 영업손실률
|
||||
val lossToSalesRatio = if (opCurrent < 0) (abs(opCurrent) / salesCurrent) * 100 else 0.0
|
||||
|
||||
return FinancialStatement(
|
||||
operatingProfitGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / abs(opPrevious)) * 100 else 0.0,
|
||||
roe = (niCurrent / equityCurrent) * 100,
|
||||
debtRatio = (debtCurrent / equityCurrent) * 100,
|
||||
quickRatio = (currentAssets / currentLiabilities) * 100,
|
||||
isOperatingProfitPositive = opCurrent > 0,
|
||||
isNetIncomePositive = niCurrent > 0,
|
||||
// 추가된 정교화 지표
|
||||
capitalImpairmentRate = capitalImpairment,
|
||||
debtAccelerationRate = debtAcceleration,
|
||||
lossToSalesRatio = lossToSalesRatio
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
|
||||
val result = mutableMapOf<String, Double>()
|
||||
val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""")
|
||||
regex.findAll(text).forEach { match ->
|
||||
val key = match.groupValues[1].trim()
|
||||
val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
result[key] = rawValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
173
src/main/kotlin/analyzer/ScalpingAnalyzer.kt
Normal file
173
src/main/kotlin/analyzer/ScalpingAnalyzer.kt
Normal file
@ -0,0 +1,173 @@
|
||||
package analyzer
|
||||
|
||||
import model.CandleData
|
||||
import service.Candle
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.*
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
class ScalpingAnalyzer {
|
||||
companion object {
|
||||
private const val SMA_SHORT = 10 // 단기 이평선 (10봉)
|
||||
private const val SMA_LONG = 20 // 장기 이평선 (20봉)
|
||||
private const val RSI_WINDOW = 14 // RSI 기간
|
||||
private const val VOL_WINDOW = 20 // 거래량 평균 기간
|
||||
private const val VOL_SURGE_THRESHOLD = 1.5 // 평소 대비 1.5배 거래량
|
||||
private const val RSI_THRESHOLD = 50.0 // RSI 매수 우위 기준
|
||||
private const val DEFAULT_SL_PCT = -1.5 // 기본 손절 라인 (-1.5%)
|
||||
private const val DEFAULT_TP_PCT = 1.5 // 기본 익절 라인 (+1.5%)
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 분봉 데이터를 분석하여 종합 신호 모델을 반환합니다.
|
||||
*/
|
||||
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 }
|
||||
|
||||
// 1. 보조 지표 계산
|
||||
val sma10 = simpleMovingAverage(closes, SMA_SHORT)
|
||||
val sma20 = simpleMovingAverage(closes, SMA_LONG)
|
||||
val rsiList = computeRSI(closes)
|
||||
val volAvg = simpleMovingAverage(volumes, VOL_WINDOW)
|
||||
val (bbUpper, _, bbLower) = bollingerBands(closes)
|
||||
|
||||
// 2. 현재 시점 데이터 추출
|
||||
val current = candles.last()
|
||||
val currentClose = current.close
|
||||
val sma10Now = sma10.lastOrNull() ?: 0.0
|
||||
val sma20Now = sma20.lastOrNull() ?: 0.0
|
||||
val rsiNow = rsiList.lastOrNull() ?: 0.0
|
||||
val volRatioNow = if (volAvg.isNotEmpty()) current.volume / volAvg.last() else 1.0
|
||||
|
||||
// 3. 정교화된 상태 판별 로직
|
||||
// [볼린저 밴드 위치] 0.0(하단) ~ 1.0(상단)
|
||||
val bbPos = if (bbUpper.isNotEmpty() && bbLower.isNotEmpty()) {
|
||||
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
|
||||
} else 0.5
|
||||
|
||||
// [전고점 돌파 확인] 최근 6봉 이내의 최고점을 거래량과 함께 돌파하는지 확인
|
||||
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
|
||||
val isBreakout = currentClose > nearHigh && volRatioNow > 2.0 // 거래량 2배 동반 돌파
|
||||
|
||||
// [일봉 이격도 과열 체크] 5일 이동평균선 대비 이격도 계산 (상위 호출부에서 계산 권장)
|
||||
// 여기서는 기술적 지표 조합으로만 판단
|
||||
val ma5 = if (candles.size >= 5) candles.takeLast(5).map { it.close }.average() else currentClose
|
||||
val isOverheated = (currentClose / ma5) * 100 > 112.0 // 12% 이상 이격 시 과열
|
||||
|
||||
// 4. 매수 신호 확정 조건
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now // 정배열
|
||||
val rsiBull = rsiNow > RSI_THRESHOLD // 매수 강도
|
||||
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD // 수급 폭발
|
||||
val bbValid = bbPos in 0.2..0.9 // 밴드 내 안정적 위치
|
||||
|
||||
val buySignal = maBull && rsiBull && volSurge && isBreakout && !isOverheated && isDailyBullish
|
||||
|
||||
// 5. 종합 점수(0~100) 산출 가중치
|
||||
val score = (if (maBull) 25 else 0) +
|
||||
(if (rsiBull) 15 else 0) +
|
||||
(if (isBreakout) 20 else 0) +
|
||||
(minOf((volRatioNow - 1.0) * 15, 20.0)).toInt() +
|
||||
(if (bbValid) 10 else 0) +
|
||||
(if (isDailyBullish) 10 else 0)
|
||||
|
||||
// 6. 성공 확률 및 위험도 계산
|
||||
val successProb = if (buySignal) 75.0 + (score / 10.0) else 30.0 + (score / 2.0)
|
||||
|
||||
return ScalpingSignalModel(
|
||||
currentPrice = currentClose,
|
||||
buySignal = buySignal,
|
||||
compositeScore = score.coerceIn(0, 100),
|
||||
successProbPct = successProb.coerceAtMost(98.0),
|
||||
riskLevel = if (volRatioNow > 5.0) "High" else "Medium",
|
||||
rsi = rsiNow,
|
||||
volRatio = volRatioNow,
|
||||
suggestedSlPrice = currentClose * (1 + DEFAULT_SL_PCT / 100),
|
||||
suggestedTpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100),
|
||||
riskRewardRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
|
||||
)
|
||||
}
|
||||
|
||||
// --- 내부 계산 유틸리티 ---
|
||||
private fun simpleMovingAverage(values: List<Double>, window: Int): List<Double> {
|
||||
return values.windowed(window).map { it.average() }
|
||||
}
|
||||
|
||||
private fun computeRSI(closes: List<Double>, window: Int = RSI_WINDOW): List<Double> {
|
||||
val rsi = mutableListOf<Double>()
|
||||
if (closes.size < window + 1) return rsi
|
||||
val changes = closes.zipWithNext { a, b -> b - a }
|
||||
for (i in window..changes.size) {
|
||||
val windowChanges = changes.subList(i - window, i)
|
||||
val gains = windowChanges.filter { it > 0 }.sum()
|
||||
val losses = windowChanges.filter { it < 0 }.map { abs(it) }.sum()
|
||||
val rs = if (losses > 0) gains / losses else Double.POSITIVE_INFINITY
|
||||
rsi.add(100.0 - (100.0 / (1.0 + rs)))
|
||||
}
|
||||
return rsi
|
||||
}
|
||||
|
||||
private fun bollingerBands(closes: List<Double>, window: Int = SMA_LONG): Triple<List<Double>, List<Double>, List<Double>> {
|
||||
val upper = mutableListOf<Double>()
|
||||
val sma = mutableListOf<Double>()
|
||||
val lower = mutableListOf<Double>()
|
||||
for (i in window..closes.size) {
|
||||
val slice = closes.subList(i - window, i)
|
||||
val mean = slice.average()
|
||||
val std = sqrt(slice.map { (it - mean).pow(2) }.average())
|
||||
sma.add(mean)
|
||||
upper.add(mean + (std * 2))
|
||||
lower.add(mean - (std * 2))
|
||||
}
|
||||
return Triple(upper, sma, lower)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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() }
|
||||
}
|
||||
202
src/main/kotlin/analyzer/TechnicalAnalyzer.kt
Normal file
202
src/main/kotlin/analyzer/TechnicalAnalyzer.kt
Normal file
@ -0,0 +1,202 @@
|
||||
package analyzer
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import model.CandleData
|
||||
import kotlin.math.abs
|
||||
import kotlin.text.toDouble
|
||||
import kotlin.text.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).all { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* [신규] 기술적 지표와 추세를 결합한 종합 신호 생성
|
||||
*/
|
||||
fun generateComprehensiveSignal(): ScalpingSignalModel {
|
||||
val scalpingAnalyzer = ScalpingAnalyzer()
|
||||
val dailyBullish = isDailyBullish()
|
||||
|
||||
// 1. 기본 스캘핑 신호 생성
|
||||
val baseSignal = scalpingAnalyzer.analyze(min30.toScalpingList(), dailyBullish)
|
||||
|
||||
// 2. 점수 정교화 (가점/감점 요인)
|
||||
var refinedScore = baseSignal.compositeScore.toDouble()
|
||||
|
||||
// [보완] 추세 동기화 가점: 월/주/일봉이 모두 상승 추세일 때
|
||||
if (calculateChange(monthly) > 0 && calculateChange(weekly) > 0 && calculateChange(daily.takeLast(5)) > 0) {
|
||||
refinedScore += 10.0
|
||||
}
|
||||
|
||||
// [보완] 자금 유입 강도(MFI) 반영
|
||||
val mfi = calculateMFI(min30)
|
||||
when {
|
||||
mfi > 80.0 -> refinedScore -= 15.0 // 과매수 권역 감점
|
||||
mfi < 20.0 -> refinedScore -= 5.0 // 자금 유출 감점
|
||||
mfi in 45.0..65.0 -> refinedScore += 5.0 // 안정적 수급 구간
|
||||
}
|
||||
|
||||
// [보완] 변동성 돌파 확인 (ATR 대비 현재 몸통 크기)
|
||||
val atr = calculateATR(min30)
|
||||
val lastCandle = min30.last()
|
||||
val bodyRange = abs(lastCandle.stck_prpr.toDouble() - lastCandle.stck_oprc.toDouble())
|
||||
if (bodyRange > atr * 1.2) refinedScore += 7.0
|
||||
|
||||
return baseSignal.copy(
|
||||
compositeScore = refinedScore.coerceIn(0.0, 100.0).toInt(),
|
||||
successProbPct = (refinedScore * 0.85).coerceAtMost(98.0)
|
||||
)
|
||||
}
|
||||
|
||||
fun isOverheatedStock(): Boolean {
|
||||
if (min30.size < 20 || daily.size < 20) return false
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
val disparityDaily = (currentPrice / ma20Daily) * 100
|
||||
|
||||
// 이격도 115% 이상이면 주의, 125% 이상이면 과열
|
||||
return disparityDaily > 115.0
|
||||
}
|
||||
|
||||
fun calculateScores(financialScore100: Int): InvestmentScores {
|
||||
val signal = generateComprehensiveSignal() // 이미 100점 만점 기반
|
||||
|
||||
// 모든 지표를 100점 스케일 내에서 조합
|
||||
val ultra = signal.compositeScore
|
||||
val short = (calculateRSI(daily) * 0.5 + (if(calculateOBV(daily) > 0) 50 else 0)).toInt()
|
||||
val mid = (if(calculateChange(weekly) > 0) 60 else 20) + (financialScore100 * 0.4).toInt()
|
||||
val long = (if(calculateChange(monthly) > 0) 50 else 10) + (financialScore100 * 0.5).toInt()
|
||||
|
||||
return InvestmentScores(
|
||||
ultraShort = ultra.coerceIn(0, 100),
|
||||
shortTerm = short.coerceIn(0, 100),
|
||||
midTerm = mid.coerceIn(0, 100),
|
||||
longTerm = long.coerceIn(0, 100)
|
||||
)
|
||||
}
|
||||
|
||||
// --- 유틸리티 함수군 (기존 로직 유지 및 보완) ---
|
||||
fun calculateATR(candles: List<CandleData>, period: Int = 14): Double {
|
||||
if (candles.size < period + 1) return 0.0
|
||||
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()
|
||||
trList.add(maxOf(high - low, abs(high - prevClose), abs(low - prevClose)))
|
||||
}
|
||||
return trList.average()
|
||||
}
|
||||
|
||||
fun calculateMFI(candles: List<CandleData>, period: Int = 14): Double {
|
||||
if (candles.size < period + 1) return 50.0
|
||||
val subList = candles.takeLast(period + 1)
|
||||
var posFlow = 0.0
|
||||
var negFlow = 0.0
|
||||
for (i in 1 until subList.size) {
|
||||
val prevTyp = (subList[i-1].stck_hgpr.toDouble() + subList[i-1].stck_lwpr.toDouble() + subList[i-1].stck_prpr.toDouble()) / 3
|
||||
val currTyp = (subList[i].stck_hgpr.toDouble() + subList[i].stck_lwpr.toDouble() + subList[i].stck_prpr.toDouble()) / 3
|
||||
val flow = currTyp * subList[i].cntg_vol.toDouble()
|
||||
if (currTyp > prevTyp) posFlow += flow else if (currTyp < prevTyp) negFlow += flow
|
||||
}
|
||||
return if (negFlow == 0.0) 100.0 else 100 - (100 / (1 + (posFlow / negFlow)))
|
||||
}
|
||||
|
||||
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 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()
|
||||
if (currClose > prevClose) obv += candles[i].cntg_vol.toDouble()
|
||||
else if (currClose < prevClose) obv -= candles[i].cntg_vol.toDouble()
|
||||
}
|
||||
return obv
|
||||
}
|
||||
|
||||
fun calculateChange(list: List<CandleData>): Double {
|
||||
if (list.isEmpty()) return 0.0
|
||||
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
|
||||
}
|
||||
|
||||
fun isDailyBullish(): Boolean {
|
||||
if (daily.size < 20) return true
|
||||
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
return currentPrice > ma20 && ma5 > prevMa5
|
||||
}
|
||||
|
||||
fun generateComprehensiveReport(finScore100: Int): String {
|
||||
val signal = generateComprehensiveSignal()
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
|
||||
// [표준화된 분석 점수] - AI에게 가이드라인 제공
|
||||
val standardizedScores = """
|
||||
- Financial Health Score: $finScore100 / 100
|
||||
- Technical Momentum Score: ${signal.compositeScore} / 100
|
||||
- Market Energy (Volume): ${"%.1f".format(signal.volRatio)}x relative to avg
|
||||
""".trimIndent()
|
||||
|
||||
// [시계열 가격 흐름] - AI에게 지지와 저항 맥락 제공
|
||||
val monthlyRange = monthly.takeLast(3).joinToString(" -> ") {
|
||||
"[H:${it.stck_hgpr}, L:${it.stck_lwpr}]"
|
||||
}
|
||||
val weeklyRange = weekly.takeLast(4).joinToString(" -> ") {
|
||||
"[H:${it.stck_hgpr}, L:${it.stck_lwpr}]"
|
||||
}
|
||||
|
||||
return """
|
||||
# [Standardized Analysis Summary]
|
||||
$standardizedScores
|
||||
|
||||
# [Historical Price Range]
|
||||
- Monthly (Last 3M): $monthlyRange
|
||||
- Weekly (Last 4W): $weeklyRange
|
||||
- Current Price: $currentPrice
|
||||
|
||||
# [Technical Context]
|
||||
- Base Position: ${"%.1f".format((currentPrice / daily.takeLast(120).map { it.stck_prpr.toDouble() }.average()) * 100)}% (120MA)
|
||||
- RSI (Daily): ${"%.1f".format(calculateRSI(daily))}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@ -27,10 +27,12 @@ import kotlinx.serialization.json.Json
|
||||
import model.DartFinancialResponse
|
||||
import model.KisSession
|
||||
import model.NaverNewsResponse
|
||||
import service.DynamicNewsScraper
|
||||
import service.FinancialAnalyzer
|
||||
import service.SafeScraper
|
||||
import service.UrlCacheManager
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
import kotlin.Double
|
||||
|
||||
object NewsService {
|
||||
@ -41,7 +43,7 @@ object NewsService {
|
||||
}
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.NONE
|
||||
level = LogLevel.BODY
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +65,9 @@ object NewsService {
|
||||
)
|
||||
(qlistNews + qlistCorpTrend).forEach { query ->
|
||||
try {
|
||||
val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH)
|
||||
val today = ZonedDateTime.now().toLocalDate() // 오늘 날짜 정보
|
||||
|
||||
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
|
||||
parameter("query", query)
|
||||
parameter("display", 4) // 최근 10개 뉴스
|
||||
@ -70,7 +75,23 @@ object NewsService {
|
||||
header("X-Naver-Client-Id", clientId)
|
||||
header("X-Naver-Client-Secret", clientSecret)
|
||||
}.body()
|
||||
SafeScraper.scrapeParallel(corpInfo,response.items.sortedBy { it.pubDate }.distinctBy { Url(it.originallink).host }.take(2) )
|
||||
val todayItems = response.items.filter { item ->
|
||||
try {
|
||||
val pubDate = ZonedDateTime.parse(item.pubDate, formatter)
|
||||
pubDate.toLocalDate() == today // 날짜가 오늘과 일치하는지 확인
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 호스트 제거 및 최종 2건 선택
|
||||
val finalItems = todayItems
|
||||
.distinctBy { Url(it.originallink).host }
|
||||
.take(2)
|
||||
|
||||
if (finalItems.isNotEmpty()) {
|
||||
SafeScraper.scrapeParallel(corpInfo, finalItems)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ 뉴스 가져오기 실패: ${e.message}")
|
||||
}
|
||||
|
||||
@ -3,6 +3,11 @@ package network// src/main/kotlin/network/RagService.kt
|
||||
import Defines.EMBEDDING_PORT
|
||||
import Defines.LLM_PORT
|
||||
import TradingLogStore
|
||||
import analyzer.FinancialAnalyzer
|
||||
import analyzer.FinancialMapper
|
||||
import analyzer.FinancialStatement
|
||||
import analyzer.InvestmentScores
|
||||
import analyzer.TechnicalAnalyzer
|
||||
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
|
||||
import dev.langchain4j.data.document.Metadata
|
||||
import dev.langchain4j.data.segment.TextSegment
|
||||
@ -23,6 +28,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@ -39,9 +45,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.lucene.store.MMapDirectory
|
||||
import org.slf4j.MDC.put
|
||||
import service.AutoTradingManager
|
||||
import service.FinancialAnalyzer
|
||||
import service.InvestmentScores
|
||||
import service.TechnicalAnalyzer
|
||||
import service.InvestmentGrade
|
||||
import service.TradingDecisionCallback
|
||||
import service.UrlCacheManager
|
||||
import java.nio.file.Paths
|
||||
@ -206,140 +210,140 @@ object RagService {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVeryRecentNews(dateStr: String?, maxHours: Long = 1): Boolean {
|
||||
if (dateStr.isNullOrBlank()) return false
|
||||
return try {
|
||||
val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH)
|
||||
val pubDate = ZonedDateTime.parse(dateStr, formatter)
|
||||
val now = ZonedDateTime.now()
|
||||
|
||||
// 현재 시간과 뉴스 발행 시간의 차이를 시간 단위로 계산
|
||||
val hoursDiff = Math.abs(ChronoUnit.HOURS.between(pubDate, now))
|
||||
hoursDiff < maxHours // 1시간 미만이면 true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) {
|
||||
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
|
||||
val totalStartTime = System.currentTimeMillis()
|
||||
|
||||
coroutineScope {
|
||||
try {
|
||||
var tradingDecision = TradingDecision()
|
||||
tradingDecision.stockCode = stockCode
|
||||
tradingDecision.analyzer = technicalAnalyzer
|
||||
tradingDecision.currentPrice = currentPrice
|
||||
|
||||
var corpInfo = DartCodeManager.getCorpCode(stockCode)
|
||||
corpInfo?.stockName = stockName
|
||||
tradingDecision.stockName = stockName
|
||||
tradingDecision.corpName = corpInfo?.cName ?: ""
|
||||
|
||||
// 1. 재무 데이터 가져오기 시간 측정
|
||||
val financialStartTime = System.currentTimeMillis()
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
|
||||
tradingDecision.financialData = financialDataDeferred.await()
|
||||
val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "")
|
||||
val financialDuration = System.currentTimeMillis() - financialStartTime
|
||||
println("⏱️ [$stockName] 재무 분석 소요: ${financialDuration}ms")
|
||||
|
||||
if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
||||
// 3. 기술적 지표 계산 시간 측정
|
||||
val techStartTime = System.currentTimeMillis()
|
||||
val financialScore = FinancialAnalyzer.calculateScore(financialStmt)
|
||||
val scores = technicalAnalyzer.calculateScores(financialScore)
|
||||
val techDuration = System.currentTimeMillis() - techStartTime
|
||||
println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms")
|
||||
val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||
if (scores.avg() > (guideLine.times(0.50))) {
|
||||
// 2. 뉴스 스크래핑 및 학습 시간 측정
|
||||
val ragStartTime = System.currentTimeMillis()
|
||||
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
||||
val questionEmbedding = embeddingModel.embed(question).content()
|
||||
|
||||
// --- 💡 [수정됨] 2. 해당 주식의 최신 뉴스 존재 여부 확인 (최대 10개) ---
|
||||
val preSearchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(questionEmbedding)
|
||||
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 해당 종목만 필터링
|
||||
.maxResults(10)
|
||||
.minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
|
||||
// 검색된 청크들 중 최근 3일 이내의 날짜를 가진 데이터가 하나라도 있는지 확인
|
||||
val hasRecentData = preSearchResult.matches().any { match ->
|
||||
val pubDate = match.embedded().metadata().getString("date")
|
||||
isRecentNews(pubDate, maxDays = 1)
|
||||
}
|
||||
|
||||
// --- 💡 [수정됨] 3. 최신 데이터가 없을 때만 브라우저 스크래핑(Playwright) 실행 ---
|
||||
val newsIngestStartTime = System.currentTimeMillis()
|
||||
if (!hasRecentData) {
|
||||
println("🌐 [$stockName] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다.")
|
||||
corpInfo?.let {
|
||||
try {
|
||||
NewsService.fetchAndIngestNews(it)
|
||||
} catch (e: Exception) {
|
||||
println("❌ [$stockName] 뉴스 스크래핑 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("✅ [$stockName] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다.")
|
||||
}
|
||||
val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime
|
||||
println("⏱️ [$stockName] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration}ms")
|
||||
|
||||
result(tradingDecision, false)
|
||||
tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
|
||||
result(tradingDecision, false)
|
||||
|
||||
// --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 ---
|
||||
// (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다)
|
||||
val finalSearchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(questionEmbedding)
|
||||
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 교차 오염 방지를 위해 필터 필수
|
||||
.maxResults(3)
|
||||
.minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정
|
||||
.build()
|
||||
)
|
||||
|
||||
println("🔎 [$stockName] RAG 검색된 문서 개수: ${finalSearchResult.matches().size}개")
|
||||
finalSearchResult.matches().forEach { match ->
|
||||
println("📊 [RAG Score: ${match.score()}] 본문: ${match.embedded().text().replace("\n", " ").take(50)}...")
|
||||
}
|
||||
|
||||
tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n") { it.embedded().text() }
|
||||
val ragDuration = System.currentTimeMillis() - ragStartTime
|
||||
println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms")
|
||||
|
||||
result(tradingDecision, false)
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "${FinancialAnalyzer.toString(financialStmt)}${scores.toString()}", true)
|
||||
|
||||
// 5. AI 최종 결정(LLM 호출) 시간 측정
|
||||
val aiDecisionStartTime = System.currentTimeMillis()
|
||||
val finalDecision = decideTrading(stockCode, scores, financialStmt, tradingDecision)
|
||||
val aiDecisionDuration = System.currentTimeMillis() - aiDecisionStartTime
|
||||
println("⏱️ [$stockName] AI 최종 판단 소요: ${aiDecisionDuration}ms")
|
||||
|
||||
val totalDuration = System.currentTimeMillis() - totalStartTime
|
||||
println("✅ [$stockName] 전체 분석 완료 총 소요: ${totalDuration}ms")
|
||||
|
||||
// 상세 로그 남기기
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "분석시간 상세: 재무(${financialDuration}ms), 뉴스(${newsIngestDuration}ms), RAG(${ragDuration}ms), AI(${aiDecisionDuration}ms), 전체 분석 완료(${totalDuration}ms)", true)
|
||||
|
||||
result(finalDecision, true)
|
||||
} else {
|
||||
println("✋ [$stockName] 기술 점수 미달로 분석 중단 ${scores.toString()}")
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "기술 점수 미달로 분석 중단")
|
||||
if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt)) {
|
||||
TradingLogStore.addLog(tradingDecision,"WATCH","우량주로 판단되나 거래량 혹은 최근 거래 점수 미달로 재분석 대상에 추가")
|
||||
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
||||
}
|
||||
tradingDecision.confidence = 1.0
|
||||
result(tradingDecision, false)
|
||||
}
|
||||
} else {
|
||||
println("🚨 [$stockName] ${FinancialAnalyzer.toString(financialStmt)} 재무 안전벨트 미달")
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, "재무 안전벨트 미달로 분석 중단 ${FinancialAnalyzer.toString(financialStmt)}")
|
||||
tradingDecision.confidence = 1.0
|
||||
result(tradingDecision, false)
|
||||
val tradingDecision = TradingDecision().apply {
|
||||
this.stockCode = stockCode
|
||||
this.analyzer = technicalAnalyzer
|
||||
this.currentPrice = currentPrice
|
||||
}
|
||||
|
||||
// [1단계] 재무 분석 및 필터링 (가장 빠름)
|
||||
val finStartTime = System.currentTimeMillis()
|
||||
val financialData = NewsService.fetchFinancialGrowth(DartCodeManager.getCorpCode(stockCode)?.cCode)
|
||||
val financialStmt = FinancialMapper.mapRawTextToStatement(financialData)
|
||||
val finDuration = System.currentTimeMillis() - finStartTime
|
||||
println("financialStmt ${FinancialAnalyzer.toString(financialStmt)} isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(financialStmt)}")
|
||||
|
||||
|
||||
// [2단계] 기술적 지표 및 과열 체크
|
||||
val techStartTime = System.currentTimeMillis()
|
||||
val financialScore = FinancialAnalyzer.calculateScore(financialStmt)
|
||||
val scores = technicalAnalyzer.calculateScores(financialScore)
|
||||
val techSignal = technicalAnalyzer.generateComprehensiveSignal()
|
||||
val techDuration = System.currentTimeMillis() - techStartTime
|
||||
println("techSignal.compositeScore ${techSignal.compositeScore}")
|
||||
|
||||
if (!FinancialAnalyzer.isSafetyBeltMet(financialStmt)) {
|
||||
logTime(stockName, "재무 미달 조기 종료", finDuration, System.currentTimeMillis() - totalStartTime)
|
||||
result(tradingDecision.apply { decision = "HOLD"; reason = "재무 안정성 부족" }, false)
|
||||
return@coroutineScope
|
||||
}
|
||||
|
||||
if (techSignal.compositeScore < 50) {
|
||||
logTime(stockName, "기술 점수 미달 조기 종료", techDuration, System.currentTimeMillis() - totalStartTime)
|
||||
result(tradingDecision.apply { decision = "HOLD"; reason = "매수 타점 미도달" }, false)
|
||||
return@coroutineScope
|
||||
}
|
||||
|
||||
// [3단계] 뉴스 RAG 및 AI 분석 (가장 오래 걸림)
|
||||
val ragStartTime = System.currentTimeMillis()
|
||||
// 1시간 이내 뉴스 존재 여부 확인 후 동적 스크래핑
|
||||
checkAndFetchRecentNews(stockName, stockCode)
|
||||
|
||||
val question = "$stockName 실적 및 향후 전망"
|
||||
val questionEmbedding = embeddingModel.embed(question).content()
|
||||
|
||||
val finalSearchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(questionEmbedding)
|
||||
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode))
|
||||
.maxResults(10) // 최신 뉴스 3개 적정
|
||||
.minScore(0.2)
|
||||
.build()
|
||||
)
|
||||
|
||||
// 3. 검색된 내용을 하나의 문자열로 합쳐서 전달
|
||||
tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n\n") {
|
||||
it.embedded().text()
|
||||
}
|
||||
|
||||
val finalDecision = decideTrading(stockName, scores, financialStmt, tradingDecision)
|
||||
val ragAiDuration = System.currentTimeMillis() - ragStartTime
|
||||
|
||||
// [4단계] 최종 로그 기록
|
||||
val totalDuration = System.currentTimeMillis() - totalStartTime
|
||||
val detailLog = "재무(${finDuration}ms), 기술(${techDuration}ms), 뉴스/AI(${ragAiDuration}ms), 전체(${totalDuration}ms)"
|
||||
TradingLogStore.addAnalyzer(stockName, stockCode, detailLog, true)
|
||||
println("$stockName[$stockCode] $detailLog")
|
||||
result(finalDecision, true)
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
println("❌ [$stockName] 분석 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkAndFetchRecentNews(stockName: String, stockCode: String) {
|
||||
val question = "$stockName 실적 전망 및 최근 이슈"
|
||||
val questionEmbedding = embeddingModel.embed(question).content()
|
||||
|
||||
// 1. 벡터 DB에서 해당 종목의 뉴스 검색
|
||||
val searchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(questionEmbedding)
|
||||
.filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode))
|
||||
.maxResults(10)
|
||||
.minScore(0.2)
|
||||
.build()
|
||||
)
|
||||
|
||||
// 2. 검색된 뉴스 중 1시간 이내(Very Recent) 데이터가 있는지 확인
|
||||
val hasHotNews = searchResult.matches().any { match ->
|
||||
val pubDate = match.embedded().metadata().getString("date")
|
||||
isVeryRecentNews(pubDate, maxHours = 1)
|
||||
}
|
||||
|
||||
// 3. 최신 뉴스가 없다면 네이버 API 및 Playwright 스크래핑 가동
|
||||
if (!hasHotNews) {
|
||||
println("🌐 [$stockName] 최근 1시간 내 분석된 뉴스가 없습니다. 실시간 스크래핑을 시작합니다.")
|
||||
val corpInfo = DartCodeManager.getCorpCode(stockCode)
|
||||
corpInfo?.let {
|
||||
try {
|
||||
// NewsService에서 오늘자 뉴스를 가져와 인덱싱 수행
|
||||
NewsService.fetchAndIngestNews(it)
|
||||
} catch (e: Exception) {
|
||||
println("❌ [$stockName] 뉴스 업데이트 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("✅ [$stockName] 최근 1시간 내 기사가 DB에 존재하여 스크래핑을 건너뜁니다.")
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 기록용 헬퍼 함수
|
||||
private fun logTime(name: String, status: String, stepMs: Long, totalMs: Long) {
|
||||
println("⏱️ [$name] $status - 단계: ${stepMs}ms / 누적: ${totalMs}ms")
|
||||
}
|
||||
|
||||
fun isUrlAlreadyIndexed(url: String): Boolean {
|
||||
// 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성
|
||||
val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url)
|
||||
@ -371,7 +375,7 @@ object RagService {
|
||||
// put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화
|
||||
// put("presence_penalty", 0.5)
|
||||
|
||||
put("max_tokens", 400)
|
||||
put("max_tokens", 200)
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
@ -418,128 +422,171 @@ object RagService {
|
||||
financialStmt: FinancialStatement,
|
||||
tempDecision: TradingDecision
|
||||
): TradingDecision? {
|
||||
val totalStartTime = System.currentTimeMillis() // 전체 시작 시간
|
||||
|
||||
var retryCount = 0
|
||||
val maxRetries = 2
|
||||
// 1-1. 재무 점수 산출 시간 측정
|
||||
val finStartTime = System.currentTimeMillis()
|
||||
val finScore100 = FinancialAnalyzer.calculateScore(financialStmt).toDouble()
|
||||
val finDuration = System.currentTimeMillis() - finStartTime
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
// 1-2. 기술 분석 및 리포트 생성 시간 측정
|
||||
val techStartTime = System.currentTimeMillis()
|
||||
val techSignal = tempDecision.analyzer?.generateComprehensiveSignal()
|
||||
val techScore100 = techSignal?.compositeScore?.toDouble() ?: 0.0
|
||||
val isOverheated = tempDecision.analyzer?.isOverheatedStock() ?: false
|
||||
tempDecision.techSummary = tempDecision.analyzer?.generateComprehensiveReport(finScore100.toInt())
|
||||
val techDuration = System.currentTimeMillis() - techStartTime
|
||||
|
||||
// 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단
|
||||
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400)
|
||||
// 1-3. 뉴스 AI 분석 시간 측정 (가장 병목이 예상되는 구간)
|
||||
val newsStartTime = System.currentTimeMillis()
|
||||
val (newsScore100, newsReason) = tempDecision.newsContext?.let {
|
||||
getAiNewsScore(it, tempDecision.techSummary ?: "")
|
||||
} ?: (50.0 to "참조 뉴스 없음")
|
||||
val newsDuration = System.currentTimeMillis() - newsStartTime
|
||||
|
||||
// 2. 뉴스 유무에 따른 동적 데이터 섹션 구성
|
||||
val newsDataSection = if (validNews != null) {
|
||||
"4. News Context: $validNews"
|
||||
} else {
|
||||
"4. News Context: No significant news available. Rely on financials."
|
||||
// 1-4. 시스템 및 가중치 합성 시간 측정
|
||||
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)
|
||||
|
||||
// 보너스 및 패널티 로직
|
||||
if (finScore100 >= 80.0 && techScore100 >= 70.0) finalConfidence += 8.0
|
||||
if (techScore100 >= 90.0 && finScore100 >= 50.0) finalConfidence += 5.0
|
||||
if (isOverheated) finalConfidence *= 0.85
|
||||
|
||||
val totalScore = (scores.ultraShort + scores.shortTerm + scores.midTerm + scores.longTerm) / 4.0
|
||||
val grade = AutoTradingManager.getInvestmentGrade(tempDecision, totalScore, finalConfidence)
|
||||
val synthDuration = System.currentTimeMillis() - synthStartTime
|
||||
|
||||
// 5. 최종 결정 및 사유 정리
|
||||
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||
var finalDecision = "HOLD"
|
||||
var finalReason = ""
|
||||
|
||||
when {
|
||||
newsScore100 < 30.0 -> {
|
||||
finalDecision = "HOLD"
|
||||
finalReason = "📉 뉴스 악재 감지: $newsReason"
|
||||
}
|
||||
|
||||
|
||||
val prompt = """
|
||||
# Task: Senior AI Investment Analyst
|
||||
|
||||
Your goal is to provide a final trading decision based on STRICT data analysis.
|
||||
|
||||
|
||||
# Data (SOURCE OF TRUTH)
|
||||
1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm})
|
||||
|
||||
2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}%
|
||||
|
||||
3. Technical Analysis Summary: ${tempDecision.techSummary ?: "No technical summary available."}
|
||||
|
||||
$newsDataSection
|
||||
|
||||
|
||||
|
||||
# Step-by-Step Analysis Logic
|
||||
|
||||
1. Financial Review: First, evaluate the 'Financials' section for long-term stability.
|
||||
|
||||
2. News Verification: Second, check 'News Context' (if available) for immediate market sentiment or specific issues.
|
||||
|
||||
3. Synthesis: Finalize the 'decision' (BUY, SELL, HOLD) by combining the Financials and News analysis.
|
||||
|
||||
4. Confidence: Assign a confidence score (0-100) based on how clearly the data points to the decision.
|
||||
|
||||
# Confidence Scoring Guide (CRITICAL)
|
||||
Assign the 'confidence' score based on these rules:
|
||||
- 80-100: When Financials are strong AND News Context clearly supports the trend.
|
||||
- 50-79: When Financials are stable but News is neutral or missing.
|
||||
- 10-49: When Financials and News contradict each other.
|
||||
- 1-9: Reserved ONLY for extreme data corruption.
|
||||
- NEVER output 0 unless the data is completely unreadable. Even a weak guess should be at least 10.
|
||||
|
||||
# Strict Constraints
|
||||
|
||||
- SCORE INTEGRITY: You MUST copy the 'System Scores' into the output JSON exactly as provided. NO TRANSFORMATION.
|
||||
|
||||
- REASON LENGTH: The "reason" field MUST be written in KOREAN and MUST be between 10 to 50 characters.
|
||||
|
||||
- JSON ONLY: Output ONLY a valid JSON object. No markdown, no pre-text, no post-text.
|
||||
|
||||
|
||||
# Output JSON Structure (STRICT NAMES)
|
||||
|
||||
{
|
||||
"ultraShortScore": ${scores.ultraShort},
|
||||
"shortTermScore": ${scores.shortTerm},
|
||||
"midTermScore": ${scores.midTerm},
|
||||
"longTermScore": ${scores.longTerm},
|
||||
"decision": "HOLD",
|
||||
"reason": "10자 이상 50자 이내의 한국어 분석 결과",
|
||||
"confidence": 0
|
||||
}
|
||||
|
||||
""".trimIndent()
|
||||
try {
|
||||
val rawResponse = callLlamaWithSchema(prompt)
|
||||
// 환각 및 루프 검사
|
||||
println("rawResponse $rawResponse")
|
||||
|
||||
|
||||
val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim()
|
||||
|
||||
val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString<TradingDecision>(sanitized)
|
||||
|
||||
// 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영)
|
||||
val reasonLen = decision.reason?.length ?: 0
|
||||
val isReasonValid = reasonLen in 5..60 // 약간의 마진 허용
|
||||
|
||||
// 점수가 보존되었는지 확인 (Scalping 점수 대조)
|
||||
val isScorePreserved = decision.ultraShortScore == scores.ultraShort.toDouble()
|
||||
|
||||
if (isReasonValid && isScorePreserved) {
|
||||
return decision.apply {
|
||||
this.stockCode = tempDecision.stockCode
|
||||
this.stockName = tempDecision.stockName
|
||||
this.corpName = tempDecision.corpName
|
||||
this.financialData = tempDecision.financialData
|
||||
this.newsContext = tempDecision.newsContext
|
||||
}
|
||||
} else {
|
||||
println("⚠️ [검증 실패] 사유길이($reasonLen) 또는 점수보존($isScorePreserved) 실패. 재시도 합니다.")
|
||||
retryCount++
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ [파싱 오류] ${e.message} - 재시도 시도 중... (${retryCount + 1})")
|
||||
retryCount++
|
||||
delay(500)
|
||||
isOverheated && finalConfidence < 85.0 -> {
|
||||
finalDecision = "HOLD"
|
||||
finalReason = "🔥 단기 과열 구간(이격도 높음)으로 인한 매수 제한"
|
||||
}
|
||||
finalConfidence >= minScore && newsScore100 >= 50.0 && grade != InvestmentGrade.LEVEL_1_SPECULATIVE -> {
|
||||
finalDecision = "BUY"
|
||||
finalReason = "✅ [${grade.displayName}] $newsReason | 종합 지표 우수"
|
||||
}
|
||||
finalConfidence < 40.0 -> {
|
||||
finalDecision = "SELL"
|
||||
finalReason = "⚠️ 종합 지표 악화로 인한 비중 축소 권장"
|
||||
}
|
||||
else -> {
|
||||
finalDecision = "HOLD"
|
||||
finalReason = "⏳ 지표 중립 또는 확신 부족 (신뢰도: ${String.format("%.1f", finalConfidence)})"
|
||||
}
|
||||
}
|
||||
// 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환
|
||||
println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.")
|
||||
|
||||
val totalDuration = System.currentTimeMillis() - totalStartTime
|
||||
|
||||
// 성능 분석 로그 출력 (CSV 형태로 출력하여 나중에 엑셀 분석 가능)
|
||||
println("⏱️ [$stockName] 처리 성능 리포트: 전체 ${totalDuration}ms | 재무 ${finDuration}ms | 기술 ${techDuration}ms | 뉴스AI ${newsDuration}ms | 합성 ${synthDuration}ms")
|
||||
|
||||
return TradingDecision().apply {
|
||||
this.stockCode = tempDecision.stockCode
|
||||
this.stockName = stockName
|
||||
this.decision = "HOLD"
|
||||
this.reason = "AI 분석 지연으로 인한 자동 관망 처리"
|
||||
this.currentPrice = tempDecision.currentPrice
|
||||
this.techSummary = tempDecision.techSummary
|
||||
this.ultraShortScore = scores.ultraShort.toDouble()
|
||||
this.shortTermScore = scores.shortTerm.toDouble()
|
||||
this.midTermScore = scores.midTerm.toDouble()
|
||||
this.longTermScore = scores.longTerm.toDouble()
|
||||
this.reason = finalReason
|
||||
this.decision = finalDecision
|
||||
this.confidence = finalConfidence
|
||||
this.investmentGrade = grade
|
||||
this.newsScore = newsScore100
|
||||
this.newsContext = tempDecision.newsContext
|
||||
this.financialData = tempDecision.financialData
|
||||
}.apply {
|
||||
if (confidence > 50.0) {
|
||||
println(this.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun getAiNewsScore(news: String,techSummary : String): Pair<Double, String> {
|
||||
val prompt = """
|
||||
# Role: Expert Quantitative & Sentiment Analyst
|
||||
# Task: Evaluate [News Text] by correlating it with [Market Context].
|
||||
|
||||
# Input 1: [Market Context] (Standardized Scores & Price History)
|
||||
$techSummary
|
||||
|
||||
# Input 2: [News Text] (Latest Headlines & Content)
|
||||
$news
|
||||
|
||||
# Evaluation Logic (Internal Reasoning):
|
||||
1. Value Gap: Compare 'Financial Score' with 'Base Position'. (e.g., High Score + Low Position = Strong Buy)
|
||||
2. Momentum Catalyst: Check if News justifies the 'Volume Intensity' and 'Weekly Breakout'.
|
||||
3. Sentiment Weight:
|
||||
- Positive: Earnings surprise, Contract win, Turnaround, Buyback. (+10~30)
|
||||
- Negative: Deficit, Lawsuit, Capital increase (Dilution). (-20~40)
|
||||
|
||||
# Operational Instructions:
|
||||
- If the news mentions specific profit figures (e.g., "72B KRW"), award a "Profit Bonus" even without YoY comparison.
|
||||
- If 'Base Position' is near 100% (at 120MA), consider it a 'Safe Entry' for long-term holding.
|
||||
|
||||
# Constraints:
|
||||
- Reason: KOREAN only, max 50 chars. Explain the "Synergy" between scores and news.
|
||||
- Output: Strictly JSON format.
|
||||
|
||||
# JSON Output:
|
||||
{
|
||||
"score": [0.0-100.0],
|
||||
"reason": "[KOREAN_REASON]"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
return try {
|
||||
val raw = callLlamaWithSchema(prompt)
|
||||
println("getAiNewsScore $raw")
|
||||
val json = Json { ignoreUnknownKeys = true }.parseToJsonElement(raw).jsonObject
|
||||
|
||||
val score = json["score"]?.jsonPrimitive?.double ?: 50.0
|
||||
val reason = json["reason"]?.jsonPrimitive?.content ?: "뉴스 분석 완료"
|
||||
|
||||
score to reason
|
||||
} catch (e: Exception) {
|
||||
50.0 to "뉴스 분석 오류 발생 (중립 처리)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 재무 점수 계산 (Max 40)
|
||||
private fun calculateFinancialPoint(fs: FinancialStatement): Double {
|
||||
var p = 0.0
|
||||
// 영업이익 흑자면 25점, 적자여도 성장 중이면 10점
|
||||
p += if (fs.isOperatingProfitPositive) 25.0 else (if (fs.operatingProfitGrowth > 30) 10.0 else 0.0)
|
||||
// ROE 10% 기준 비례 배분 (Max 10)
|
||||
p += (fs.roe / 15.0 * 10.0).coerceIn(0.0, 10.0)
|
||||
// 부채비율 100% 이하면 5점 만점
|
||||
p += if (fs.debtRatio <= 100.0) 5.0 else (150.0 - fs.debtRatio).coerceAtLeast(0.0) / 10.0
|
||||
return p
|
||||
}
|
||||
|
||||
private fun calculateSystemPoint(s: InvestmentScores): Double {
|
||||
val midLongAvg = (s.midTerm + s.longTerm) / 2.0
|
||||
val base = (midLongAvg / 100.0 * 15.0) + (s.ultraShort / 100.0 * 10.0)
|
||||
// 정배열 보너스: 초단기 > 단기 > 중기 점수 순서일 때 가점
|
||||
val alignmentBonus = if (s.ultraShort > s.shortTerm && s.shortTerm > s.midTerm) 3.0 else 0.0
|
||||
return (base + alignmentBonus).coerceIn(0.0, 25.0)
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -548,16 +595,18 @@ Assign the 'confidence' score based on these rules:
|
||||
class TradingDecision {
|
||||
var corpName : String = ""
|
||||
var stockName : String = ""
|
||||
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
|
||||
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
|
||||
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
|
||||
val longTermScore: Double = 0.0
|
||||
var ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
|
||||
var shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
|
||||
var midTermScore: Double = 0.0 // 중기 (주봉/재무)
|
||||
var longTermScore: Double = 0.0
|
||||
// [추가] 화면 전환용 종목명
|
||||
var currentPrice: Double = 0.0
|
||||
var stockCode: String = ""
|
||||
var decision: String? = null
|
||||
var reason: String? = null
|
||||
var confidence: Double = 0.0
|
||||
var newsScore : Double = 0.0
|
||||
var investmentGrade : InvestmentGrade? = null
|
||||
var techSummary : String? = null
|
||||
var newsContext : String? = null
|
||||
var financialData : String? = null
|
||||
@ -590,88 +639,13 @@ decision: $decision
|
||||
reason: $reason
|
||||
confidence: $confidence
|
||||
기술 분석: $techSummary
|
||||
뉴스: $newsContext
|
||||
재무재표: $financialData
|
||||
|
||||
뉴스 점수: $newsScore
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
//재무재표: $financialData
|
||||
//뉴스: $newsContext
|
||||
|
||||
|
||||
|
||||
object FinancialMapper {
|
||||
/**
|
||||
* 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환
|
||||
*/
|
||||
fun mapRawTextToStatement(rawText: String): FinancialStatement {
|
||||
if (rawText.isBlank()) {
|
||||
return FinancialStatement()
|
||||
}
|
||||
// println(rawText)
|
||||
val currentValues = extractYearlyValues(rawText, "당기")
|
||||
val previousValues = extractYearlyValues(rawText, "전기")
|
||||
|
||||
// 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100
|
||||
val opCurrent = currentValues["영업이익"] ?: 0.0
|
||||
val opPrevious = previousValues["영업이익"] ?: 0.0
|
||||
val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0
|
||||
|
||||
// 2. 당기순이익 증가율
|
||||
val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0
|
||||
val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0
|
||||
val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0
|
||||
|
||||
// 3. ROE: 당기순이익 / 당기 자본총계 * 100
|
||||
val equityCurrent = currentValues["자본총계"] ?: 1.0
|
||||
val roe = (niCurrent / equityCurrent) * 100
|
||||
|
||||
// 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100
|
||||
val debtCurrent = currentValues["부채총계"] ?: 0.0
|
||||
val debtRatio = (debtCurrent / equityCurrent) * 100
|
||||
|
||||
// 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100
|
||||
val currentAssets = currentValues["유동자산"] ?: 0.0
|
||||
val currentLiabilities = currentValues["유동부채"] ?: 1.0
|
||||
val quickRatio = (currentAssets / currentLiabilities) * 100
|
||||
|
||||
return FinancialStatement(
|
||||
operatingProfitGrowth = opGrowth,
|
||||
netIncomeGrowth = niGrowth,
|
||||
roe = roe,
|
||||
debtRatio = debtRatio,
|
||||
quickRatio = quickRatio,
|
||||
isOperatingProfitPositive = opCurrent > 0,
|
||||
isNetIncomePositive = niCurrent > 0
|
||||
).apply {
|
||||
println("당기순이익: ${niCurrent} , isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(this)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractYearlyValues(text: String, type: String): Map<String, Double> {
|
||||
val result = mutableMapOf<String, Double>()
|
||||
|
||||
// 핵심 수정: 항목명 뒤에 (당기) 또는 (전기)가 오고, 그 직후의 숫자(마이너스, 쉼표 포함)를 캡처
|
||||
// 쉼표나 공백으로 끝나는 지점까지 찾습니다.
|
||||
val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""")
|
||||
|
||||
regex.findAll(text).forEach { match ->
|
||||
val key = match.groupValues[1].trim()
|
||||
// 숫자 내 쉼표 제거 후 Double 변환
|
||||
val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
result[key] = rawValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
data class FinancialStatement(
|
||||
val revenueGrowth: Double = 0.0, // 매출액 증가율
|
||||
val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율
|
||||
val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율
|
||||
val roe: Double = 0.0, // ROE
|
||||
val debtRatio: Double = 0.0, // 부채비율
|
||||
val quickRatio: Double = 0.0, // 당좌비율
|
||||
val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부
|
||||
val isNetIncomePositive: Boolean = false
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -100,13 +100,16 @@ object LlamaServerManager {
|
||||
"-c", if (port == EMBEDDING_PORT) "512" else "8192",
|
||||
"-ngl", optimalGpuLayers.toString(),
|
||||
"-t", optimalThreads.toString(),
|
||||
"--embedding"
|
||||
"--embedding",
|
||||
"--mlock", // RAM 고정으로 스왑 방지
|
||||
"--no-mmap"
|
||||
)
|
||||
if (port != EMBEDDING_PORT) { // 텍스트 생성용 모델에만 적용
|
||||
command.addAll(listOf(
|
||||
"-b", "512", // Batch size (토큰 병렬 처리량 제한으로 연산 안정화)
|
||||
"--threads-batch", optimalThreads.toString(),
|
||||
"-fa","on" // Flash Attention 활성화 (메모리 절약 및 긴 컨텍스트 연산 안정성 증가)
|
||||
"-fa","on", // Flash Attention 활성화 (메모리 절약 및 긴 컨텍스트 연산 안정성 증가)
|
||||
"--cont-batching"
|
||||
))
|
||||
}
|
||||
scope.launch {
|
||||
@ -118,7 +121,7 @@ object LlamaServerManager {
|
||||
val env = pb.environment()
|
||||
// 특정 GPU 선택 (내장 GPU가 여러 개일 경우)
|
||||
env["GGML_VULKAN_DEVICE"] = "0"
|
||||
|
||||
env["GGML_VULKAN_MAX_NODES"] = "1"
|
||||
// DLL 로드 경로 강제 지정 (bin 폴더 내 dll 참조)
|
||||
val libraryPath = File(binPath).parentFile.absolutePath
|
||||
val currentPath = System.getenv("PATH") ?: ""
|
||||
|
||||
@ -1,117 +1,117 @@
|
||||
// src/main/kotlin/ui/ActiveTradeRow.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun ActiveTradeRow(
|
||||
item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
|
||||
onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// 상태에 따른 UI 구성 요소 정의
|
||||
val (statusText, statusColor, backgroundColor) = when (item.status) {
|
||||
"PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑
|
||||
"MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑
|
||||
"SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강
|
||||
"COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색
|
||||
else -> Triple("알 수 없음", Color.Black, Color.White)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 2.dp)
|
||||
.clickable { onClick() },
|
||||
elevation = 2.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
backgroundColor = backgroundColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 좌측 정보 영역
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 상태 배지 표시
|
||||
Surface(
|
||||
color = statusColor,
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = statusText,
|
||||
color = Color.White,
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
// 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시)
|
||||
val detailText = when (item.status) {
|
||||
"PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
|
||||
"MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
|
||||
else -> "주문번호: ${item.orderNo} ${item.orderedPrice} ${item.quantity}"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${item.code} | $detailText",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 우측 액션 및 수량 영역
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
if (item.status == "PENDING_BUY" || item.status == "SELLING") {
|
||||
// 진행 중인 주문인 경우 취소 버튼 노출
|
||||
Button(
|
||||
onClick = { onCancelClick() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier = Modifier.height(28.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
) {
|
||||
Text("취소", fontSize = 11.sp)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onCancelClick() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier = Modifier.height(28.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
) {
|
||||
Text("취소", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${item.quantity}주",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/ActiveTradeRow.kt
|
||||
//package ui
|
||||
//
|
||||
//import AutoTradeItem
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//
|
||||
//@Composable
|
||||
//fun ActiveTradeRow(
|
||||
// item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
|
||||
// onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
|
||||
// onClick: () -> Unit
|
||||
//) {
|
||||
// // 상태에 따른 UI 구성 요소 정의
|
||||
// val (statusText, statusColor, backgroundColor) = when (item.status) {
|
||||
// "PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑
|
||||
// "MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑
|
||||
// "SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강
|
||||
// "COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색
|
||||
// else -> Triple("알 수 없음", Color.Black, Color.White)
|
||||
// }
|
||||
//
|
||||
// Card(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .padding(vertical = 4.dp, horizontal = 2.dp)
|
||||
// .clickable { onClick() },
|
||||
// elevation = 2.dp,
|
||||
// shape = RoundedCornerShape(4.dp),
|
||||
// backgroundColor = backgroundColor
|
||||
// ) {
|
||||
// Row(
|
||||
// modifier = Modifier.padding(12.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// // 좌측 정보 영역
|
||||
// Column(modifier = Modifier.weight(1f)) {
|
||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// // 상태 배지 표시
|
||||
// Surface(
|
||||
// color = statusColor,
|
||||
// shape = RoundedCornerShape(2.dp),
|
||||
// modifier = Modifier.padding(end = 6.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = statusText,
|
||||
// color = Color.White,
|
||||
// fontSize = 9.sp,
|
||||
// modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
// fontWeight = FontWeight.Bold
|
||||
// )
|
||||
// }
|
||||
// Text(
|
||||
// text = item.name,
|
||||
// style = MaterialTheme.typography.body2,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// maxLines = 1
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// // 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시)
|
||||
// val detailText = when (item.status) {
|
||||
// "PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
|
||||
// "MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
|
||||
// else -> "주문번호: ${item.orderNo} ${item.orderedPrice} ${item.quantity}"
|
||||
// }
|
||||
//
|
||||
// Text(
|
||||
// text = "${item.code} | $detailText",
|
||||
// fontSize = 11.sp,
|
||||
// color = Color.Gray
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// // 우측 액션 및 수량 영역
|
||||
// Column(horizontalAlignment = Alignment.End) {
|
||||
// if (item.status == "PENDING_BUY" || item.status == "SELLING") {
|
||||
// // 진행 중인 주문인 경우 취소 버튼 노출
|
||||
// Button(
|
||||
// onClick = { onCancelClick() },
|
||||
// contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
// modifier = Modifier.height(28.dp),
|
||||
// colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
// ) {
|
||||
// Text("취소", fontSize = 11.sp)
|
||||
// }
|
||||
// } else {
|
||||
// Button(
|
||||
// onClick = { onCancelClick() },
|
||||
// contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
// modifier = Modifier.height(28.dp),
|
||||
// colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
// ) {
|
||||
// Text("취소", fontSize = 11.sp)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Text(
|
||||
// text = "${item.quantity}주",
|
||||
// fontSize = 12.sp,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = statusColor
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,100 +1,99 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.KisSession
|
||||
import service.AutoTradingManager
|
||||
import service.TechnicalAnalyzer
|
||||
import service.TradingDecisionCallback
|
||||
|
||||
@Composable
|
||||
fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
|
||||
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||
var code by remember(stockCode) {
|
||||
aiOpinion = ""
|
||||
mutableStateOf(stockCode.isNotEmpty())
|
||||
}
|
||||
var isAnalyzing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// KisSession의 전역 설정을 참조
|
||||
val isModelConfigured = remember(KisSession.config.modelPath) {
|
||||
val path = KisSession.config.modelPath
|
||||
path.isNotEmpty() && java.io.File(path).exists()
|
||||
}
|
||||
|
||||
Card(
|
||||
elevation = 2.dp,
|
||||
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.15F)
|
||||
.verticalScroll(rememberScrollState()) // 스크롤 활성화
|
||||
.padding(16.dp)
|
||||
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isAnalyzing = true
|
||||
try {
|
||||
AutoTradingManager.addStock(currentPrice.replace(",","").toDouble(),technicalAnalyzer,stockName,stockCode) { decision,success ->
|
||||
aiOpinion = decision.toString()
|
||||
isAnalyzing = !success
|
||||
tradingDecisionCallback.invoke(decision,success)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
aiOpinion = "분석 중 오류 발생: ${e.message}"
|
||||
println(aiOpinion)
|
||||
isAnalyzing = false
|
||||
} finally {
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.layout.Column
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.rememberScrollState
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.foundation.verticalScroll
|
||||
//import androidx.compose.material.Button
|
||||
//import androidx.compose.material.Card
|
||||
//import androidx.compose.material.CircularProgressIndicator
|
||||
//import androidx.compose.material.Divider
|
||||
//import androidx.compose.material.MaterialTheme
|
||||
//import androidx.compose.material.Text
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.runtime.getValue
|
||||
//import androidx.compose.runtime.mutableStateOf
|
||||
//import androidx.compose.runtime.remember
|
||||
//import androidx.compose.runtime.rememberCoroutineScope
|
||||
//import androidx.compose.runtime.setValue
|
||||
//import androidx.compose.ui.*
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.KisSession
|
||||
//import service.AutoTradingManager
|
||||
//import service.TradingDecisionCallback
|
||||
//
|
||||
//@Composable
|
||||
//fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
|
||||
// var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||
// var code by remember(stockCode) {
|
||||
// aiOpinion = ""
|
||||
// mutableStateOf(stockCode.isNotEmpty())
|
||||
// }
|
||||
// var isAnalyzing by remember { mutableStateOf(false) }
|
||||
// val scope = rememberCoroutineScope()
|
||||
//
|
||||
// // KisSession의 전역 설정을 참조
|
||||
// val isModelConfigured = remember(KisSession.config.modelPath) {
|
||||
// val path = KisSession.config.modelPath
|
||||
// path.isNotEmpty() && java.io.File(path).exists()
|
||||
// }
|
||||
//
|
||||
// Card(
|
||||
// elevation = 2.dp,
|
||||
// backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// ) {
|
||||
// Column(modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .fillMaxHeight(0.15F)
|
||||
// .verticalScroll(rememberScrollState()) // 스크롤 활성화
|
||||
// .padding(16.dp)
|
||||
// .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) {
|
||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(
|
||||
// text = if (isModelConfigured) "${stockName} AI 투자 전략" else "⚠️ AI 설정 필요",
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
|
||||
// )
|
||||
// Spacer(Modifier.weight(1f))
|
||||
// Button(
|
||||
// onClick = {
|
||||
// scope.launch {
|
||||
// isAnalyzing = true
|
||||
// try {
|
||||
// AutoTradingManager.addStock(currentPrice.replace(",","").toDouble(),technicalAnalyzer,stockName,stockCode) { decision,success ->
|
||||
// aiOpinion = decision.toString()
|
||||
// isAnalyzing = !success
|
||||
// tradingDecisionCallback.invoke(decision,success)
|
||||
// }
|
||||
// } catch (e: Exception) {
|
||||
// aiOpinion = "분석 중 오류 발생: ${e.message}"
|
||||
// println(aiOpinion)
|
||||
// isAnalyzing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isAnalyzing && code
|
||||
) {
|
||||
if (isAnalyzing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("뉴스 분석 중...")
|
||||
} else {
|
||||
Text("분석 요청")
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 8.dp))
|
||||
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
// } finally {
|
||||
//// isAnalyzing = false
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// enabled = !isAnalyzing && code
|
||||
// ) {
|
||||
// if (isAnalyzing) {
|
||||
// CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
|
||||
// Spacer(Modifier.width(8.dp))
|
||||
// Text("뉴스 분석 중...")
|
||||
// } else {
|
||||
// Text("분석 요청")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Divider(Modifier.padding(vertical = 8.dp))
|
||||
// Text(text = aiOpinion, style = MaterialTheme.typography.body2)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,95 +1,95 @@
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일)
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.toAutoTradeItem
|
||||
import network.KisTradeService
|
||||
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt
|
||||
|
||||
@Composable
|
||||
fun AutoTradeSection(
|
||||
isDomestic: Boolean,
|
||||
tradeService: KisTradeService,
|
||||
refreshTrigger: Int, // 갱신 트리거 추가
|
||||
onRefresh: () -> Unit,
|
||||
onItemSelect: (AutoTradeItem) -> Unit,
|
||||
onItemCancel: (AutoTradeItem) -> Unit
|
||||
) {
|
||||
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
|
||||
var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
|
||||
// refreshTrigger가 바뀔 때마다 실행됨
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
// 1. 서버에서 실제 미체결 내역 가져오기
|
||||
val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
|
||||
|
||||
// 2. DB에서 로컬 감시 데이터 가져오기
|
||||
val localTrades = DatabaseFactory.getActiveAutoTrades()
|
||||
|
||||
// 3. 리스트 병합 및 동기화
|
||||
val mergedList = mutableListOf<AutoTradeItem>()
|
||||
|
||||
// (A) DB에 있는 항목 처리
|
||||
localTrades.forEach { local ->
|
||||
val serverMatch = serverUnfilled.find { it.orderNo == local.orderNo }
|
||||
if (local.status != "COMPLETED" && serverMatch == null) {
|
||||
// 서버에 없으면 만료 처리
|
||||
mergedList.add(local.copy(status = "EXPIRED"))
|
||||
} else {
|
||||
// 서버에 있으면 그대로 표시 (필요시 잔량 등 업데이트)
|
||||
mergedList.add(local.copy(remainedQuantity = serverMatch?.remainedQuantity ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
// (B) 서버에는 있지만 DB에는 없는 항목(수동 주문 등) 추가
|
||||
val manualOrders = serverUnfilled.filter { server -> localTrades.none { it.orderNo == server.orderNo } }
|
||||
mergedList.addAll(manualOrders.map { it.copy(status = "MANUAL_ORDER") }) // 수동 주문 상태 등으로 표시
|
||||
|
||||
tradeList = mergedList
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 강제 갱신 버튼
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||
contentDescription = "새로고침",
|
||||
tint = Color(0xFF0E62CF),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn {
|
||||
items(tradeList) { item ->
|
||||
ActiveTradeRow(
|
||||
item = item,
|
||||
onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
|
||||
onClick = { onItemSelect(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/AutoTradeSection.kt (신규 파일)
|
||||
//package ui
|
||||
//
|
||||
//import AutoTradeItem
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.icons.filled.Refresh
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import model.toAutoTradeItem
|
||||
//import network.KisTradeService
|
||||
//
|
||||
//// src/main/kotlin/ui/AutoTradeSection.kt
|
||||
//
|
||||
//@Composable
|
||||
//fun AutoTradeSection(
|
||||
// isDomestic: Boolean,
|
||||
// tradeService: KisTradeService,
|
||||
// refreshTrigger: Int, // 갱신 트리거 추가
|
||||
// onRefresh: () -> Unit,
|
||||
// onItemSelect: (AutoTradeItem) -> Unit,
|
||||
// onItemCancel: (AutoTradeItem) -> Unit
|
||||
//) {
|
||||
// // 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
|
||||
// var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
|
||||
// // refreshTrigger가 바뀔 때마다 실행됨
|
||||
// LaunchedEffect(refreshTrigger) {
|
||||
// // 1. 서버에서 실제 미체결 내역 가져오기
|
||||
// val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
|
||||
//
|
||||
// // 2. DB에서 로컬 감시 데이터 가져오기
|
||||
// val localTrades = DatabaseFactory.getActiveAutoTrades()
|
||||
//
|
||||
// // 3. 리스트 병합 및 동기화
|
||||
// val mergedList = mutableListOf<AutoTradeItem>()
|
||||
//
|
||||
// // (A) DB에 있는 항목 처리
|
||||
// localTrades.forEach { local ->
|
||||
// val serverMatch = serverUnfilled.find { it.orderNo == local.orderNo }
|
||||
// if (local.status != "COMPLETED" && serverMatch == null) {
|
||||
// // 서버에 없으면 만료 처리
|
||||
// mergedList.add(local.copy(status = "EXPIRED"))
|
||||
// } else {
|
||||
// // 서버에 있으면 그대로 표시 (필요시 잔량 등 업데이트)
|
||||
// mergedList.add(local.copy(remainedQuantity = serverMatch?.remainedQuantity ?: 0))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // (B) 서버에는 있지만 DB에는 없는 항목(수동 주문 등) 추가
|
||||
// val manualOrders = serverUnfilled.filter { server -> localTrades.none { it.orderNo == server.orderNo } }
|
||||
// mergedList.addAll(manualOrders.map { it.copy(status = "MANUAL_ORDER") }) // 수동 주문 상태 등으로 표시
|
||||
//
|
||||
// tradeList = mergedList
|
||||
// }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
// Row(
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text("진행 중인 거래", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
//
|
||||
// // 강제 갱신 버튼
|
||||
// IconButton(
|
||||
// onClick = onRefresh,
|
||||
// modifier = Modifier.size(24.dp)
|
||||
// ) {
|
||||
// Icon(
|
||||
// imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||
// contentDescription = "새로고침",
|
||||
// tint = Color(0xFF0E62CF),
|
||||
// modifier = Modifier.size(18.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// LazyColumn {
|
||||
// items(tradeList) { item ->
|
||||
// ActiveTradeRow(
|
||||
// item = item,
|
||||
// onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
|
||||
// onClick = { onItemSelect(item) }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,180 +1,180 @@
|
||||
// src/main/kotlin/ui/BalanceSection.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.UnifiedBalance
|
||||
import network.KisTradeService
|
||||
|
||||
@Composable
|
||||
fun BalanceSection(
|
||||
tradeService: KisTradeService,
|
||||
refreshTrigger: Int, // 갱신 트리거 추가
|
||||
onRefresh: () -> Unit,
|
||||
onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit
|
||||
) {
|
||||
var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
// 화면 진입 시 및 갱신 시 데이터 로드
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
isLoading = true
|
||||
tradeService.fetchIntegratedBalance().onSuccess {
|
||||
balanceData = it
|
||||
}.onFailure {
|
||||
println("❌ 잔고 로드 실패: ${it.localizedMessage}")
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "나의 자산",
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
// 강제 갱신 버튼
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||
contentDescription = "새로고침",
|
||||
tint = Color(0xFF0E62CF),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 1. 자산 요약 카드
|
||||
BalanceSummaryCard(balanceData)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 2. 통합 보유 종목 리스트
|
||||
Text(
|
||||
text = "보유 종목",
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f, true)) {
|
||||
items(balanceData?.holdings ?: emptyList()) { holding ->
|
||||
UnifiedStockItemRow(holding) {
|
||||
onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
Card(
|
||||
elevation = 2.dp,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
backgroundColor = androidx.compose.ui.graphics.Color.White
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
Text(
|
||||
text = "${summary?.totalAsset ?: "0"} 원",
|
||||
style = MaterialTheme.typography.h5,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0
|
||||
val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
|
||||
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color.DarkGray
|
||||
|
||||
Text(
|
||||
text = "수익률: ${if (rate > 0) "+" else ""}$rate%",
|
||||
color = color,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
|
||||
val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0
|
||||
val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
elevation = 1.dp
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
// 국내/해외 구분 배지
|
||||
Surface(
|
||||
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD)
|
||||
else androidx.compose.ui.graphics.Color(0xFFF3E5F5),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (holding.isDomestic) "국내" else "해외",
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontSize = 10.sp,
|
||||
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color(0xFF7B1FA2)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold)
|
||||
// 손익분기점 표시 추가
|
||||
Text(
|
||||
"손익분기: ${String.format("%,.0f", breakEvenPrice)}원",
|
||||
fontSize = 10.sp, color = Color(0xFF666666)
|
||||
)
|
||||
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
|
||||
Text(
|
||||
text = "${if (rate > 0) "+" else ""}${holding.profitRate}%",
|
||||
color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}",
|
||||
fontSize = 11.sp, color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/BalanceSection.kt
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.icons.filled.Refresh
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import model.UnifiedBalance
|
||||
//import network.KisTradeService
|
||||
//
|
||||
//@Composable
|
||||
//fun BalanceSection(
|
||||
// tradeService: KisTradeService,
|
||||
// refreshTrigger: Int, // 갱신 트리거 추가
|
||||
// onRefresh: () -> Unit,
|
||||
// onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit
|
||||
//) {
|
||||
// var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
|
||||
// var isLoading by remember { mutableStateOf(false) }
|
||||
//
|
||||
// // 화면 진입 시 및 갱신 시 데이터 로드
|
||||
// LaunchedEffect(refreshTrigger) {
|
||||
// isLoading = true
|
||||
// tradeService.fetchIntegratedBalance().onSuccess {
|
||||
// balanceData = it
|
||||
// }.onFailure {
|
||||
// println("❌ 잔고 로드 실패: ${it.localizedMessage}")
|
||||
// }
|
||||
// isLoading = false
|
||||
// }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Row(
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(
|
||||
// text = "나의 자산",
|
||||
// style = MaterialTheme.typography.h6,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// modifier = Modifier.padding(bottom = 8.dp)
|
||||
// )
|
||||
// // 강제 갱신 버튼
|
||||
// IconButton(
|
||||
// onClick = onRefresh,
|
||||
// modifier = Modifier.size(24.dp)
|
||||
// ) {
|
||||
// Icon(
|
||||
// imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||
// contentDescription = "새로고침",
|
||||
// tint = Color(0xFF0E62CF),
|
||||
// modifier = Modifier.size(18.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// // 1. 자산 요약 카드
|
||||
// BalanceSummaryCard(balanceData)
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// // 2. 통합 보유 종목 리스트
|
||||
// Text(
|
||||
// text = "보유 종목",
|
||||
// style = MaterialTheme.typography.subtitle1,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// modifier = Modifier.padding(vertical = 8.dp)
|
||||
// )
|
||||
//
|
||||
// if (isLoading) {
|
||||
// Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
|
||||
// CircularProgressIndicator()
|
||||
// }
|
||||
// } else {
|
||||
// LazyColumn(modifier = Modifier.weight(1f, true)) {
|
||||
// items(balanceData?.holdings ?: emptyList()) { holding ->
|
||||
// UnifiedStockItemRow(holding) {
|
||||
// onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
// Card(
|
||||
// elevation = 2.dp,
|
||||
// shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// backgroundColor = androidx.compose.ui.graphics.Color.White
|
||||
// ) {
|
||||
//
|
||||
// Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
// Text(
|
||||
// text = "${summary?.totalAsset ?: "0"} 원",
|
||||
// style = MaterialTheme.typography.h5,
|
||||
// fontWeight = FontWeight.Bold
|
||||
// )
|
||||
//
|
||||
// val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0
|
||||
// val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
|
||||
// else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
|
||||
// else androidx.compose.ui.graphics.Color.DarkGray
|
||||
//
|
||||
// Text(
|
||||
// text = "수익률: ${if (rate > 0) "+" else ""}$rate%",
|
||||
// color = color,
|
||||
// style = MaterialTheme.typography.body2,
|
||||
// fontWeight = FontWeight.Medium
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
|
||||
// val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0
|
||||
// val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0
|
||||
//
|
||||
// Card(
|
||||
// modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
// elevation = 1.dp
|
||||
// ) {
|
||||
// Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
// Column(modifier = Modifier.weight(1f)) {
|
||||
// Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
// // 국내/해외 구분 배지
|
||||
// Surface(
|
||||
// color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD)
|
||||
// else androidx.compose.ui.graphics.Color(0xFFF3E5F5),
|
||||
// shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = if (holding.isDomestic) "국내" else "해외",
|
||||
// modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
// fontSize = 10.sp,
|
||||
// color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue
|
||||
// else androidx.compose.ui.graphics.Color(0xFF7B1FA2)
|
||||
// )
|
||||
// }
|
||||
// Spacer(Modifier.width(4.dp))
|
||||
// Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
// }
|
||||
// Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
// }
|
||||
//
|
||||
// Column(horizontalAlignment = Alignment.End) {
|
||||
// Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold)
|
||||
// // 손익분기점 표시 추가
|
||||
// Text(
|
||||
// "손익분기: ${String.format("%,.0f", breakEvenPrice)}원",
|
||||
// fontSize = 10.sp, color = Color(0xFF666666)
|
||||
// )
|
||||
// val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
|
||||
// Text(
|
||||
// text = "${if (rate > 0) "+" else ""}${holding.profitRate}%",
|
||||
// color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
|
||||
// fontSize = 12.sp,
|
||||
// fontWeight = FontWeight.Bold
|
||||
// )
|
||||
// Text(
|
||||
// "매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}",
|
||||
// fontSize = 11.sp, color = Color.Gray
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,66 +1,66 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import model.CandleData
|
||||
|
||||
@Composable
|
||||
fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
if (data.isEmpty()) return
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
// 데이터가 적을 때도 일정한 너비를 유지하도록 개선
|
||||
val maxDisplayCount = 50
|
||||
val candleWidth = width / maxOf(data.size, maxDisplayCount)
|
||||
val spacing = candleWidth * 0.2f
|
||||
|
||||
// 가격 범위 계산 (여백 추가)
|
||||
val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
|
||||
val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
|
||||
val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 }
|
||||
val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백
|
||||
|
||||
fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat()
|
||||
|
||||
data.forEachIndexed { index, candle ->
|
||||
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
val close = candle.stck_prpr.toDoubleOrNull() ?: 0.0
|
||||
val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
|
||||
val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
|
||||
|
||||
val isRising = close >= open
|
||||
val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
|
||||
val x = index * candleWidth + spacing / 2
|
||||
val currentCandleWidth = candleWidth - spacing
|
||||
|
||||
// 2. 꼬리 그리기 (High-Low Line)
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x + currentCandleWidth / 2, getY(high)),
|
||||
end = Offset(x + currentCandleWidth / 2, getY(low)),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
|
||||
// 3. 몸통 그리기 (Open-Close Rect)
|
||||
val bodyTop = getY(maxOf(open, close))
|
||||
val bodyBottom = getY(minOf(open, close))
|
||||
val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장
|
||||
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x, bodyTop),
|
||||
size = Size(currentCandleWidth, bodyHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.Canvas
|
||||
//import androidx.compose.foundation.layout.fillMaxSize
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.geometry.Offset
|
||||
//import androidx.compose.ui.geometry.Size
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
//import model.CandleData
|
||||
//
|
||||
//@Composable
|
||||
//fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
// if (data.isEmpty()) return
|
||||
//
|
||||
// Canvas(modifier = modifier.fillMaxSize()) {
|
||||
// val width = size.width
|
||||
// val height = size.height
|
||||
//
|
||||
// // 데이터가 적을 때도 일정한 너비를 유지하도록 개선
|
||||
// val maxDisplayCount = 50
|
||||
// val candleWidth = width / maxOf(data.size, maxDisplayCount)
|
||||
// val spacing = candleWidth * 0.2f
|
||||
//
|
||||
// // 가격 범위 계산 (여백 추가)
|
||||
// val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
|
||||
// val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
|
||||
// val priceRange = (maxPrice - minPrice).let { if (it == 0.0) 1.0 else it * 1.1 }
|
||||
// val basePrice = minPrice - (priceRange * 0.05) // 아래쪽 여백
|
||||
//
|
||||
// fun getY(price: Double): Float = (height - ((price - basePrice) / priceRange * height)).toFloat()
|
||||
//
|
||||
// data.forEachIndexed { index, candle ->
|
||||
// val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
// val close = candle.stck_prpr.toDoubleOrNull() ?: 0.0
|
||||
// val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
|
||||
// val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
|
||||
//
|
||||
// val isRising = close >= open
|
||||
// val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
//
|
||||
// val x = index * candleWidth + spacing / 2
|
||||
// val currentCandleWidth = candleWidth - spacing
|
||||
//
|
||||
// // 2. 꼬리 그리기 (High-Low Line)
|
||||
// drawLine(
|
||||
// color = color,
|
||||
// start = Offset(x + currentCandleWidth / 2, getY(high)),
|
||||
// end = Offset(x + currentCandleWidth / 2, getY(low)),
|
||||
// strokeWidth = 2f
|
||||
// )
|
||||
//
|
||||
// // 3. 몸통 그리기 (Open-Close Rect)
|
||||
// val bodyTop = getY(maxOf(open, close))
|
||||
// val bodyBottom = getY(minOf(open, close))
|
||||
// val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장
|
||||
//
|
||||
// drawRect(
|
||||
// color = color,
|
||||
// topLeft = Offset(x, bodyTop),
|
||||
// size = Size(currentCandleWidth, bodyHeight)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,495 +1,495 @@
|
||||
// src/main/kotlin/ui/DashboardScreen.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import network.TradingDecision
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.CandleData
|
||||
import model.ConfigIndex
|
||||
import model.ExecutionData
|
||||
import model.KisSession
|
||||
import model.StockBasicInfo
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import service.AutoTradingManager
|
||||
import service.TechnicalAnalyzer
|
||||
import service.TradingDecisionCallback
|
||||
import util.MarketUtil
|
||||
import kotlin.collections.mutableListOf
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen() {
|
||||
val tradeService = remember { KisTradeService }
|
||||
val wsManager = remember { KisWebSocketManager }
|
||||
val scope = rememberCoroutineScope()
|
||||
var selectedStockCode by remember { mutableStateOf("") }
|
||||
var selectedStockName by remember { mutableStateOf("") }
|
||||
var isDomestic by remember { mutableStateOf(true) }
|
||||
var selectedStockQuantity by remember { mutableStateOf("0") }
|
||||
|
||||
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
||||
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
||||
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
|
||||
|
||||
|
||||
var min30 by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
var daySummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
var weekSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
|
||||
|
||||
|
||||
var callback = object : TradingDecisionCallback {
|
||||
override fun invoke(decision: TradingDecision?, isSuccess: Boolean) {
|
||||
if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
|
||||
decision?.stockCode?.let { stockCode ->
|
||||
decision?.stockName?.let { stockName ->
|
||||
selectedStockCode = stockCode
|
||||
selectedStockName = stockName
|
||||
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
|
||||
}
|
||||
}
|
||||
|
||||
}else if (isSuccess && decision != null) {
|
||||
if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) {
|
||||
selectedStockCode = decision.stockCode
|
||||
selectedStockName = decision.stockName
|
||||
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
|
||||
}
|
||||
// 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
|
||||
completeTradingDecision = decision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 리소스 정리는 여전히 DisposableEffect에서 수행
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
AutoTradingManager.stopDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
// 중앙 관리용 상태들
|
||||
var refreshTrigger by remember { mutableStateOf(0) }
|
||||
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
|
||||
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
|
||||
|
||||
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
|
||||
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
// setupAutoTradingWatchdog(tradeService,callback)
|
||||
}
|
||||
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
|
||||
suspend fun syncAndExecute(orderNo: String) {
|
||||
if (processingIds.contains(orderNo)) return
|
||||
processingIds.add(orderNo)
|
||||
|
||||
try {
|
||||
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||
val execData = executionCache[orderNo]
|
||||
|
||||
if (dbItem != null && execData != null && execData.isFilled) {
|
||||
if (dbItem.status == TradeStatus.PENDING_BUY) {
|
||||
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
|
||||
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
|
||||
|
||||
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
|
||||
|
||||
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||
|
||||
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
|
||||
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
|
||||
|
||||
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
|
||||
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
||||
|
||||
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
|
||||
|
||||
tradeService.postOrder(
|
||||
stockCode = dbItem.code,
|
||||
qty = dbItem.quantity.toString(),
|
||||
price = finalTargetPrice.toLong().toString(),
|
||||
isBuy = false
|
||||
).onSuccess { newSellOrderNo ->
|
||||
// 익절가 업데이트 및 상태 변경
|
||||
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
|
||||
// (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
|
||||
|
||||
executionCache.remove(orderNo)
|
||||
refreshTrigger++
|
||||
}.onFailure {
|
||||
println("❌ 익절 주문 실패: ${it.message}")
|
||||
}
|
||||
} else if (dbItem.status == TradeStatus.SELLING) {
|
||||
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
|
||||
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||
executionCache.remove(orderNo)
|
||||
refreshTrigger++
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processingIds.remove(orderNo)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// 1. 웹소켓 연결
|
||||
wsManager.connect()
|
||||
|
||||
// 2. [기동 시 동기화 시나리오]
|
||||
scope.launch {
|
||||
// (1) 서버 미체결 내역 로드
|
||||
val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
|
||||
val serverOrderNos = serverOrders.map { it.ord_no }
|
||||
|
||||
// (2) DB 상태 대조 및 EXPIRED 전환
|
||||
DatabaseFactory.syncWithServer(serverOrderNos)
|
||||
|
||||
// (3) 활성 감시 종목 구독 재개
|
||||
val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
|
||||
val monitoringCodes = monitoringTrades.map { it.code }.toSet()
|
||||
wsManager.updateSubscriptions(monitoringCodes)
|
||||
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
||||
wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
|
||||
scope.launch {
|
||||
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
|
||||
executionCache[orderNo] = exec
|
||||
syncAndExecute(orderNo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||
// [좌측 25%] 내 자산 및 통합 잔고
|
||||
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
|
||||
BalanceSection(tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
selectedStockQuantity = qty
|
||||
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// [중앙 45%] 실시간 정보 및 주문
|
||||
Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
|
||||
if (selectedStockCode.isNotEmpty()) {
|
||||
StockDetailSection(
|
||||
min30 = min30,
|
||||
daySummary = daySummary,
|
||||
monthSummary = monthSummary,
|
||||
weekSummary = weekSummary,
|
||||
yearSummary = yearSummary,
|
||||
stockCode = selectedStockCode,
|
||||
stockName = selectedStockName,
|
||||
holdingQuantity = selectedStockQuantity,
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
wsManager = wsManager,
|
||||
onOrderSaved = { orderNo ->
|
||||
scope.launch {
|
||||
syncAndExecute(orderNo) // 매칭 시도
|
||||
}
|
||||
},
|
||||
completeTradingDecision = completeTradingDecision,
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("분석할 종목을 선택하세요", color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) {
|
||||
AiAnalysisView(
|
||||
technicalAnalyzer = TechnicalAnalyzer().apply {
|
||||
this.min30 = min30
|
||||
this.daily = daySummary
|
||||
this.weekly = weekSummary
|
||||
this.monthly = monthSummary
|
||||
this.weekly = weekSummary
|
||||
},
|
||||
stockCode = selectedStockCode,
|
||||
stockName = selectedStockName,
|
||||
currentPrice = "0",
|
||||
trades = wsManager.tradeLogs,
|
||||
tradingDecisionCallback = { decision,bool ->
|
||||
if (bool && decision != null && KisSession.config.isSimulation) {
|
||||
completeTradingDecision = decision
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp))
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2), // 2열 병렬 배치
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().weight(0.3f)
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||
Text(
|
||||
"💰 거래 기본 설정",
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
var defaults = arrayOf(
|
||||
ConfigIndex.TAX_INDEX,
|
||||
ConfigIndex.PROFIT_INDEX,
|
||||
ConfigIndex.BUY_WEIGHT_INDEX,
|
||||
ConfigIndex.MAX_BUDGET_INDEX,
|
||||
ConfigIndex.MAX_PRICE_INDEX,
|
||||
ConfigIndex.MIN_PRICE_INDEX,
|
||||
ConfigIndex.MIN_PURCHASE_SCORE_INDEX,
|
||||
ConfigIndex.MAX_COUNT_INDEX,
|
||||
)
|
||||
items(defaults.size) { index ->
|
||||
val configKey = defaults.get(index)
|
||||
|
||||
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
|
||||
var localText by remember(configKey) {
|
||||
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||
}
|
||||
|
||||
// 저장 로직을 공통 함수로 분리
|
||||
val saveAction = {
|
||||
var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||
if (configKey.label.contains("PROFIT")) {
|
||||
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
|
||||
}
|
||||
KisSession.config.setValues(configKey, newValue)
|
||||
DatabaseFactory.saveConfig(KisSession.config)
|
||||
println("💾 저장됨: ${configKey.label} = $newValue")
|
||||
}
|
||||
|
||||
var text = if (configKey.label.contains("PROFIT")) {
|
||||
"${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}"
|
||||
} else {
|
||||
localText
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { localText = it }, // 화면에는 즉시 반영
|
||||
label = { Text(configKey.label) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState ->
|
||||
// 2. 포커스를 잃었을 때 저장
|
||||
if (!focusState.isFocused) {
|
||||
saveAction()
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Decimal
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
// 3. 엔터(Done) 키를 눌렀을 때 저장
|
||||
onDone = {
|
||||
saveAction()
|
||||
}
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||
Text(
|
||||
"💰매수 정책 및 기대 수익률",
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
var defaults2 = arrayOf(
|
||||
arrayOf(ConfigIndex.GRADE_5_BUY,
|
||||
ConfigIndex.GRADE_5_PROFIT,),
|
||||
arrayOf(ConfigIndex.GRADE_4_BUY,
|
||||
ConfigIndex.GRADE_4_PROFIT,),
|
||||
arrayOf(ConfigIndex.GRADE_3_BUY,
|
||||
ConfigIndex.GRADE_3_PROFIT,),
|
||||
arrayOf(ConfigIndex.GRADE_2_BUY,
|
||||
ConfigIndex.GRADE_2_PROFIT,),
|
||||
arrayOf(ConfigIndex.GRADE_1_BUY,
|
||||
ConfigIndex.GRADE_1_PROFIT,),
|
||||
)
|
||||
for (items in defaults2) {
|
||||
val common = findLongestCommonSubstring(items.first().label,items.last().label)
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||
Text(
|
||||
common,
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(items.size) { index ->
|
||||
val configKey = items.get(index)
|
||||
|
||||
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
|
||||
var localText by remember(configKey) {
|
||||
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||
}
|
||||
|
||||
var labelText by remember(configKey) {
|
||||
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||
}
|
||||
|
||||
val saveAction = {
|
||||
var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||
//// src/main/kotlin/ui/DashboardScreen.kt
|
||||
//package ui
|
||||
//
|
||||
KisSession.config.setValues(configKey, newValue)
|
||||
DatabaseFactory.saveConfig(KisSession.config)
|
||||
println("💾 저장됨: ${configKey.label} = $newValue")
|
||||
labelText = if (configKey.name.contains("PROFIT")) {
|
||||
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
|
||||
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
|
||||
ConfigIndex.TAX_INDEX)}"
|
||||
} else {
|
||||
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
|
||||
}
|
||||
}
|
||||
|
||||
labelText = if (configKey.name.contains("PROFIT")) {
|
||||
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
|
||||
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
|
||||
ConfigIndex.TAX_INDEX)} "
|
||||
} else {
|
||||
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = localText,
|
||||
onValueChange = { localText = it }, // 화면에는 즉시 반영
|
||||
label = { Text(labelText) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState ->
|
||||
// 2. 포커스를 잃었을 때 저장
|
||||
if (!focusState.isFocused) {
|
||||
saveAction()
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Decimal
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
// 3. 엔터(Done) 키를 눌렀을 때 저장
|
||||
onDone = {
|
||||
saveAction()
|
||||
}
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
|
||||
AutoTradeSection(
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger , // 트리거 전달
|
||||
onItemCancel = { item ->
|
||||
scope.launch {
|
||||
tradeService.cancelOrder(item.orderNo,item.code).onSuccess {
|
||||
refreshTrigger++
|
||||
}
|
||||
}
|
||||
},
|
||||
onItemSelect = { item ->
|
||||
selectedStockCode = item.code
|
||||
selectedStockName = item.name
|
||||
isDomestic = item.isDomestic
|
||||
})
|
||||
}
|
||||
VerticalDivider()
|
||||
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
|
||||
MarketSection(tradeService) { code, name, isDom ->
|
||||
val info = StockBasicInfo(
|
||||
code = code,
|
||||
name = name,
|
||||
isDomestic = isDom
|
||||
)
|
||||
selectedStockInfo = info
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun VerticalDivider() {
|
||||
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
|
||||
}
|
||||
|
||||
fun findLongestCommonSubstring(s1: String, s2: String): String {
|
||||
if (s1.isEmpty() || s2.isEmpty()) return ""
|
||||
|
||||
var longest = ""
|
||||
// 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
|
||||
val reference = if (s1.length <= s2.length) s1 else s2
|
||||
val target = if (s1.length <= s2.length) s2 else s1
|
||||
|
||||
for (i in reference.indices) {
|
||||
for (j in (i + longest.length + 1)..reference.length) {
|
||||
val sub = reference.substring(i, j)
|
||||
if (target.contains(sub)) {
|
||||
if (sub.length > longest.length) {
|
||||
longest = sub
|
||||
}
|
||||
} else {
|
||||
// target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
fun getRemaining(original: String, common: String): String {
|
||||
if (common.isEmpty()) return original
|
||||
// 가장 처음 발견되는 공통 문자열을 한 번만 제거
|
||||
return original.replaceFirst(common, "").trim()
|
||||
}
|
||||
//import AutoTradeItem
|
||||
//import network.TradingDecision
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.grid.GridCells
|
||||
//import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
//import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
//import androidx.compose.foundation.text.KeyboardActions
|
||||
//import androidx.compose.foundation.text.KeyboardOptions
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.focus.onFocusChanged
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.input.ImeAction
|
||||
//import androidx.compose.ui.text.input.KeyboardType
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.CandleData
|
||||
//import model.ConfigIndex
|
||||
//import model.ExecutionData
|
||||
//import model.KisSession
|
||||
//import model.StockBasicInfo
|
||||
//import network.KisTradeService
|
||||
//import network.KisWebSocketManager
|
||||
//import service.AutoTradingManager
|
||||
//import service.TechnicalAnalyzer
|
||||
//import service.TradingDecisionCallback
|
||||
//import util.MarketUtil
|
||||
//import kotlin.collections.mutableListOf
|
||||
//
|
||||
//@Composable
|
||||
//fun DashboardScreen() {
|
||||
// val tradeService = remember { KisTradeService }
|
||||
// val wsManager = remember { KisWebSocketManager }
|
||||
// val scope = rememberCoroutineScope()
|
||||
// var selectedStockCode by remember { mutableStateOf("") }
|
||||
// var selectedStockName by remember { mutableStateOf("") }
|
||||
// var isDomestic by remember { mutableStateOf(true) }
|
||||
// var selectedStockQuantity by remember { mutableStateOf("0") }
|
||||
//
|
||||
// var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
||||
// var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
||||
// var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
|
||||
//
|
||||
//
|
||||
// var min30 by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
// var daySummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
// var weekSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
// var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
// var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||
//
|
||||
//
|
||||
//
|
||||
// var callback = object : TradingDecisionCallback {
|
||||
// override fun invoke(decision: TradingDecision?, isSuccess: Boolean) {
|
||||
// if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
|
||||
// decision?.stockCode?.let { stockCode ->
|
||||
// decision?.stockName?.let { stockName ->
|
||||
// selectedStockCode = stockCode
|
||||
// selectedStockName = stockName
|
||||
// isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// }else if (isSuccess && decision != null) {
|
||||
// if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) {
|
||||
// selectedStockCode = decision.stockCode
|
||||
// selectedStockName = decision.stockName
|
||||
// isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
|
||||
// }
|
||||
// // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
|
||||
// completeTradingDecision = decision
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
//// 리소스 정리는 여전히 DisposableEffect에서 수행
|
||||
// DisposableEffect(Unit) {
|
||||
// onDispose {
|
||||
// AutoTradingManager.stopDiscovery()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//// 중앙 관리용 상태들
|
||||
// var refreshTrigger by remember { mutableStateOf(0) }
|
||||
// // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
|
||||
// val executionCache = remember { mutableMapOf<String, ExecutionData>() }
|
||||
//
|
||||
// // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
|
||||
//
|
||||
// LaunchedEffect(refreshTrigger) {
|
||||
//// setupAutoTradingWatchdog(tradeService,callback)
|
||||
// }
|
||||
// val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
|
||||
// suspend fun syncAndExecute(orderNo: String) {
|
||||
// if (processingIds.contains(orderNo)) return
|
||||
// processingIds.add(orderNo)
|
||||
//
|
||||
// try {
|
||||
// val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||
// val execData = executionCache[orderNo]
|
||||
//
|
||||
// if (dbItem != null && execData != null && execData.isFilled) {
|
||||
// if (dbItem.status == TradeStatus.PENDING_BUY) {
|
||||
// // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
|
||||
// val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
|
||||
//
|
||||
// // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
|
||||
//
|
||||
// val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||
//
|
||||
// // 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
|
||||
// val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
|
||||
//
|
||||
// // 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
|
||||
// val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
||||
//
|
||||
// println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
|
||||
//
|
||||
// tradeService.postOrder(
|
||||
// stockCode = dbItem.code,
|
||||
// qty = dbItem.quantity.toString(),
|
||||
// price = finalTargetPrice.toLong().toString(),
|
||||
// isBuy = false
|
||||
// ).onSuccess { newSellOrderNo ->
|
||||
// // 익절가 업데이트 및 상태 변경
|
||||
// DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
|
||||
// // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
|
||||
//
|
||||
// executionCache.remove(orderNo)
|
||||
// refreshTrigger++
|
||||
// }.onFailure {
|
||||
// println("❌ 익절 주문 실패: ${it.message}")
|
||||
// }
|
||||
// } else if (dbItem.status == TradeStatus.SELLING) {
|
||||
// println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
|
||||
// DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||
// executionCache.remove(orderNo)
|
||||
// refreshTrigger++
|
||||
// }
|
||||
// }
|
||||
// } finally {
|
||||
// processingIds.remove(orderNo)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// LaunchedEffect(Unit) {
|
||||
// // 1. 웹소켓 연결
|
||||
// wsManager.connect()
|
||||
//
|
||||
// // 2. [기동 시 동기화 시나리오]
|
||||
// scope.launch {
|
||||
// // (1) 서버 미체결 내역 로드
|
||||
// val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
|
||||
// val serverOrderNos = serverOrders.map { it.ord_no }
|
||||
//
|
||||
// // (2) DB 상태 대조 및 EXPIRED 전환
|
||||
// DatabaseFactory.syncWithServer(serverOrderNos)
|
||||
//
|
||||
// // (3) 활성 감시 종목 구독 재개
|
||||
// val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
|
||||
// val monitoringCodes = monitoringTrades.map { it.code }.toSet()
|
||||
// wsManager.updateSubscriptions(monitoringCodes)
|
||||
//
|
||||
// refreshTrigger++
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// // 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
||||
// wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
|
||||
// scope.launch {
|
||||
// val exec = ExecutionData(orderNo, code, price, qty, isBuy)
|
||||
// executionCache[orderNo] = exec
|
||||
// syncAndExecute(orderNo)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||
// // [좌측 25%] 내 자산 및 통합 잔고
|
||||
// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
|
||||
// BalanceSection(tradeService,
|
||||
// onRefresh = { refreshTrigger++ },
|
||||
// refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
|
||||
// selectedStockCode = code
|
||||
// selectedStockName = name
|
||||
// isDomestic = isDom
|
||||
// selectedStockQuantity = qty
|
||||
// println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// VerticalDivider()
|
||||
//
|
||||
// // [중앙 45%] 실시간 정보 및 주문
|
||||
// Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
|
||||
// if (selectedStockCode.isNotEmpty()) {
|
||||
// StockDetailSection(
|
||||
// min30 = min30,
|
||||
// daySummary = daySummary,
|
||||
// monthSummary = monthSummary,
|
||||
// weekSummary = weekSummary,
|
||||
// yearSummary = yearSummary,
|
||||
// stockCode = selectedStockCode,
|
||||
// stockName = selectedStockName,
|
||||
// holdingQuantity = selectedStockQuantity,
|
||||
// isDomestic = isDomestic,
|
||||
// tradeService = tradeService,
|
||||
// wsManager = wsManager,
|
||||
// onOrderSaved = { orderNo ->
|
||||
// scope.launch {
|
||||
// syncAndExecute(orderNo) // 매칭 시도
|
||||
// }
|
||||
// },
|
||||
// completeTradingDecision = completeTradingDecision,
|
||||
// )
|
||||
// } else {
|
||||
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// Text("분석할 종목을 선택하세요", color = Color.Gray)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// VerticalDivider()
|
||||
//
|
||||
// Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) {
|
||||
// AiAnalysisView(
|
||||
// technicalAnalyzer = TechnicalAnalyzer().apply {
|
||||
// this.min30 = min30
|
||||
// this.daily = daySummary
|
||||
// this.weekly = weekSummary
|
||||
// this.monthly = monthSummary
|
||||
// this.weekly = weekSummary
|
||||
// },
|
||||
// stockCode = selectedStockCode,
|
||||
// stockName = selectedStockName,
|
||||
// currentPrice = "0",
|
||||
// trades = wsManager.tradeLogs,
|
||||
// tradingDecisionCallback = { decision,bool ->
|
||||
// if (bool && decision != null && KisSession.config.isSimulation) {
|
||||
// completeTradingDecision = decision
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
// Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp))
|
||||
// LazyVerticalGrid(
|
||||
// columns = GridCells.Fixed(2), // 2열 병렬 배치
|
||||
// horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// modifier = Modifier.fillMaxWidth().weight(0.3f)
|
||||
// ) {
|
||||
// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||
// Text(
|
||||
// "💰 거래 기본 설정",
|
||||
// style = MaterialTheme.typography.h6,
|
||||
// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
// )
|
||||
// }
|
||||
// var defaults = arrayOf(
|
||||
// ConfigIndex.TAX_INDEX,
|
||||
// ConfigIndex.PROFIT_INDEX,
|
||||
// ConfigIndex.BUY_WEIGHT_INDEX,
|
||||
// ConfigIndex.MAX_BUDGET_INDEX,
|
||||
// ConfigIndex.MAX_PRICE_INDEX,
|
||||
// ConfigIndex.MIN_PRICE_INDEX,
|
||||
// ConfigIndex.MIN_PURCHASE_SCORE_INDEX,
|
||||
// ConfigIndex.MAX_COUNT_INDEX,
|
||||
// )
|
||||
// items(defaults.size) { index ->
|
||||
// val configKey = defaults.get(index)
|
||||
//
|
||||
// // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
|
||||
// var localText by remember(configKey) {
|
||||
// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||
// }
|
||||
//
|
||||
// // 저장 로직을 공통 함수로 분리
|
||||
// val saveAction = {
|
||||
// var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||
// if (configKey.label.contains("PROFIT")) {
|
||||
// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
|
||||
// }
|
||||
// KisSession.config.setValues(configKey, newValue)
|
||||
// DatabaseFactory.saveConfig(KisSession.config)
|
||||
// println("💾 저장됨: ${configKey.label} = $newValue")
|
||||
// }
|
||||
//
|
||||
// var text = if (configKey.label.contains("PROFIT")) {
|
||||
// "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}"
|
||||
// } else {
|
||||
// localText
|
||||
// }
|
||||
//
|
||||
// OutlinedTextField(
|
||||
// value = text,
|
||||
// onValueChange = { localText = it }, // 화면에는 즉시 반영
|
||||
// label = { Text(configKey.label) },
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .onFocusChanged { focusState ->
|
||||
// // 2. 포커스를 잃었을 때 저장
|
||||
// if (!focusState.isFocused) {
|
||||
// saveAction()
|
||||
// }
|
||||
// },
|
||||
// keyboardOptions = KeyboardOptions(
|
||||
// imeAction = ImeAction.Done,
|
||||
// keyboardType = KeyboardType.Decimal
|
||||
// ),
|
||||
// keyboardActions = KeyboardActions(
|
||||
// // 3. 엔터(Done) 키를 눌렀을 때 저장
|
||||
// onDone = {
|
||||
// saveAction()
|
||||
// }
|
||||
// ),
|
||||
// singleLine = true
|
||||
// )
|
||||
// }
|
||||
// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||
// Text(
|
||||
// "💰매수 정책 및 기대 수익률",
|
||||
// style = MaterialTheme.typography.h6,
|
||||
// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
// )
|
||||
// }
|
||||
// var defaults2 = arrayOf(
|
||||
// arrayOf(ConfigIndex.GRADE_5_BUY,
|
||||
// ConfigIndex.GRADE_5_PROFIT,),
|
||||
// arrayOf(ConfigIndex.GRADE_4_BUY,
|
||||
// ConfigIndex.GRADE_4_PROFIT,),
|
||||
// arrayOf(ConfigIndex.GRADE_3_BUY,
|
||||
// ConfigIndex.GRADE_3_PROFIT,),
|
||||
// arrayOf(ConfigIndex.GRADE_2_BUY,
|
||||
// ConfigIndex.GRADE_2_PROFIT,),
|
||||
// arrayOf(ConfigIndex.GRADE_1_BUY,
|
||||
// ConfigIndex.GRADE_1_PROFIT,),
|
||||
// )
|
||||
// for (items in defaults2) {
|
||||
// val common = findLongestCommonSubstring(items.first().label,items.last().label)
|
||||
// item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||
// Text(
|
||||
// common,
|
||||
// style = MaterialTheme.typography.h6,
|
||||
// modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// items(items.size) { index ->
|
||||
// val configKey = items.get(index)
|
||||
//
|
||||
// // 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
|
||||
// var localText by remember(configKey) {
|
||||
// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||
// }
|
||||
//
|
||||
// var labelText by remember(configKey) {
|
||||
// mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||
// }
|
||||
//
|
||||
// val saveAction = {
|
||||
// var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||
////
|
||||
// KisSession.config.setValues(configKey, newValue)
|
||||
// DatabaseFactory.saveConfig(KisSession.config)
|
||||
// println("💾 저장됨: ${configKey.label} = $newValue")
|
||||
// labelText = if (configKey.name.contains("PROFIT")) {
|
||||
// getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
|
||||
// ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
|
||||
// ConfigIndex.TAX_INDEX)}"
|
||||
// } else {
|
||||
// getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// labelText = if (configKey.name.contains("PROFIT")) {
|
||||
// getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
|
||||
// ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
|
||||
// ConfigIndex.TAX_INDEX)} "
|
||||
// } else {
|
||||
// getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
|
||||
// }
|
||||
//
|
||||
// OutlinedTextField(
|
||||
// value = localText,
|
||||
// onValueChange = { localText = it }, // 화면에는 즉시 반영
|
||||
// label = { Text(labelText) },
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .onFocusChanged { focusState ->
|
||||
// // 2. 포커스를 잃었을 때 저장
|
||||
// if (!focusState.isFocused) {
|
||||
// saveAction()
|
||||
// }
|
||||
// },
|
||||
// keyboardOptions = KeyboardOptions(
|
||||
// imeAction = ImeAction.Done,
|
||||
// keyboardType = KeyboardType.Decimal
|
||||
// ),
|
||||
// keyboardActions = KeyboardActions(
|
||||
// // 3. 엔터(Done) 키를 눌렀을 때 저장
|
||||
// onDone = {
|
||||
// saveAction()
|
||||
// }
|
||||
// ),
|
||||
// singleLine = true
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// VerticalDivider()
|
||||
// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
|
||||
// AutoTradeSection(
|
||||
// isDomestic = isDomestic,
|
||||
// tradeService = tradeService,
|
||||
// onRefresh = { refreshTrigger++ },
|
||||
// refreshTrigger = refreshTrigger , // 트리거 전달
|
||||
// onItemCancel = { item ->
|
||||
// scope.launch {
|
||||
// tradeService.cancelOrder(item.orderNo,item.code).onSuccess {
|
||||
// refreshTrigger++
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// onItemSelect = { item ->
|
||||
// selectedStockCode = item.code
|
||||
// selectedStockName = item.name
|
||||
// isDomestic = item.isDomestic
|
||||
// })
|
||||
// }
|
||||
// VerticalDivider()
|
||||
// // [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
// Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
|
||||
// MarketSection(tradeService) { code, name, isDom ->
|
||||
// val info = StockBasicInfo(
|
||||
// code = code,
|
||||
// name = name,
|
||||
// isDomestic = isDom
|
||||
// )
|
||||
// selectedStockInfo = info
|
||||
// selectedStockCode = code
|
||||
// selectedStockName = name
|
||||
// isDomestic = isDom
|
||||
// println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
//}
|
||||
//
|
||||
//
|
||||
//
|
||||
//@Composable
|
||||
//fun VerticalDivider() {
|
||||
// Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
|
||||
//}
|
||||
//
|
||||
//fun findLongestCommonSubstring(s1: String, s2: String): String {
|
||||
// if (s1.isEmpty() || s2.isEmpty()) return ""
|
||||
//
|
||||
// var longest = ""
|
||||
// // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
|
||||
// val reference = if (s1.length <= s2.length) s1 else s2
|
||||
// val target = if (s1.length <= s2.length) s2 else s1
|
||||
//
|
||||
// for (i in reference.indices) {
|
||||
// for (j in (i + longest.length + 1)..reference.length) {
|
||||
// val sub = reference.substring(i, j)
|
||||
// if (target.contains(sub)) {
|
||||
// if (sub.length > longest.length) {
|
||||
// longest = sub
|
||||
// }
|
||||
// } else {
|
||||
// // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return longest
|
||||
//}
|
||||
//
|
||||
//fun getRemaining(original: String, common: String): String {
|
||||
// if (common.isEmpty()) return original
|
||||
// // 가장 처음 발견되는 공통 문자열을 한 번만 제거
|
||||
// return original.replaceFirst(common, "").trim()
|
||||
//}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,101 +1,101 @@
|
||||
// src/main/kotlin/ui/MarketSection.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import network.KisTradeService
|
||||
|
||||
@Composable
|
||||
fun MarketSection(
|
||||
tradeService: KisTradeService,
|
||||
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(RankingType.VOLUME) }
|
||||
var isDomestic by remember { mutableStateOf(true) }
|
||||
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
// 탭 또는 국가 변경 시 데이터 로드
|
||||
LaunchedEffect(selectedTab, isDomestic) {
|
||||
isLoading = true
|
||||
tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess {
|
||||
rankingList = it
|
||||
}.onFailure {
|
||||
rankingList = emptyList()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [1] 상단 타이틀 및 국내/해외 토글
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 국내/해외 전환 스위치 방식
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
Switch(
|
||||
checked = !isDomestic,
|
||||
onCheckedChange = { isDomestic = !it },
|
||||
colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [2] 랭킹 타입 탭 (상승, 하락, 거래량 등)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedTab.ordinal,
|
||||
backgroundColor = Color.Transparent,
|
||||
contentColor = Color.Black,
|
||||
edgePadding = 0.dp,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]),
|
||||
color = Color(0xFFE03E2D)
|
||||
)
|
||||
}
|
||||
) {
|
||||
RankingType.values().forEach { type ->
|
||||
Tab(
|
||||
selected = selectedTab == type,
|
||||
onClick = { selectedTab = type },
|
||||
text = { Text(type.title, fontSize = 12.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// [3] 랭킹 리스트
|
||||
if (isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(rankingList.withIndex().toList()) { (index, stock) ->
|
||||
MarketStockItemRow(index + 1, stock) {
|
||||
onStockSelect(stock.code, stock.name, isDomestic)
|
||||
}
|
||||
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/MarketSection.kt
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import model.RankingStock
|
||||
//import model.RankingType
|
||||
//import network.KisTradeService
|
||||
//
|
||||
//@Composable
|
||||
//fun MarketSection(
|
||||
// tradeService: KisTradeService,
|
||||
// onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
|
||||
//) {
|
||||
// var selectedTab by remember { mutableStateOf(RankingType.VOLUME) }
|
||||
// var isDomestic by remember { mutableStateOf(true) }
|
||||
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
|
||||
// var isLoading by remember { mutableStateOf(false) }
|
||||
//
|
||||
// // 탭 또는 국가 변경 시 데이터 로드
|
||||
// LaunchedEffect(selectedTab, isDomestic) {
|
||||
// isLoading = true
|
||||
// tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess {
|
||||
// rankingList = it
|
||||
// }.onFailure {
|
||||
// rankingList = emptyList()
|
||||
// }
|
||||
// isLoading = false
|
||||
// }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxSize()) {
|
||||
// // [1] 상단 타이틀 및 국내/해외 토글
|
||||
// Row(
|
||||
// modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// horizontalArrangement = Arrangement.SpaceBetween
|
||||
// ) {
|
||||
// Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
|
||||
//
|
||||
// // 국내/해외 전환 스위치 방식
|
||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
// Switch(
|
||||
// checked = !isDomestic,
|
||||
// onCheckedChange = { isDomestic = !it },
|
||||
// colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // [2] 랭킹 타입 탭 (상승, 하락, 거래량 등)
|
||||
// ScrollableTabRow(
|
||||
// selectedTabIndex = selectedTab.ordinal,
|
||||
// backgroundColor = Color.Transparent,
|
||||
// contentColor = Color.Black,
|
||||
// edgePadding = 0.dp,
|
||||
// indicator = { tabPositions ->
|
||||
// TabRowDefaults.Indicator(
|
||||
// Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]),
|
||||
// color = Color(0xFFE03E2D)
|
||||
// )
|
||||
// }
|
||||
// ) {
|
||||
// RankingType.values().forEach { type ->
|
||||
// Tab(
|
||||
// selected = selectedTab == type,
|
||||
// onClick = { selectedTab = type },
|
||||
// text = { Text(type.title, fontSize = 12.sp) }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// // [3] 랭킹 리스트
|
||||
// if (isLoading) {
|
||||
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
// }
|
||||
// } else {
|
||||
// LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
// items(rankingList.withIndex().toList()) { (index, stock) ->
|
||||
// MarketStockItemRow(index + 1, stock) {
|
||||
// onStockSelect(stock.code, stock.name, isDomestic)
|
||||
// }
|
||||
// Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,62 +1,62 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.RankingStock
|
||||
import model.RankingType
|
||||
import network.KisTradeService
|
||||
|
||||
|
||||
// src/main/kotlin/ui/MarketStockItemRow.kt
|
||||
@Composable
|
||||
fun MarketStockItemRow(
|
||||
rank: Int,
|
||||
stock: RankingStock,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 순위 표시
|
||||
Text(
|
||||
text = rank.toString(),
|
||||
modifier = Modifier.width(24.dp),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1)
|
||||
Text(stock.code, fontSize = 10.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
|
||||
Text(
|
||||
text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%",
|
||||
fontSize = 11.sp,
|
||||
color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.*
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import model.RankingStock
|
||||
//import model.RankingType
|
||||
//import network.KisTradeService
|
||||
//
|
||||
//
|
||||
//// src/main/kotlin/ui/MarketStockItemRow.kt
|
||||
//@Composable
|
||||
//fun MarketStockItemRow(
|
||||
// rank: Int,
|
||||
// stock: RankingStock,
|
||||
// onClick: () -> Unit
|
||||
//) {
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .clickable { onClick() }
|
||||
// .padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// // 순위 표시
|
||||
// Text(
|
||||
// text = rank.toString(),
|
||||
// modifier = Modifier.width(24.dp),
|
||||
// fontSize = 14.sp,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray
|
||||
// )
|
||||
//
|
||||
// Column(modifier = Modifier.weight(1f)) {
|
||||
// Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1)
|
||||
// Text(stock.code, fontSize = 10.sp, color = Color.Gray)
|
||||
// }
|
||||
//
|
||||
// Column(horizontalAlignment = Alignment.End) {
|
||||
// Text(
|
||||
// text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L),
|
||||
// fontSize = 13.sp,
|
||||
// fontWeight = FontWeight.Bold
|
||||
// )
|
||||
// val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
|
||||
// Text(
|
||||
// text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%",
|
||||
// fontSize = 11.sp,
|
||||
// color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,57 +1,57 @@
|
||||
// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합)
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.CandleData
|
||||
|
||||
@Composable
|
||||
fun PeriodTrendCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
val avgPrice = if (data.isEmpty()) "0"
|
||||
else String.format("%,d", data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average().toLong())
|
||||
|
||||
Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// [좌측] 라벨 및 평균가
|
||||
Column(modifier = Modifier.weight(0.4f)) {
|
||||
Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
// [우측] 간소화된 그래프 (Sparkline)
|
||||
Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) {
|
||||
if (data.isNotEmpty()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val prices = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }
|
||||
val max = prices.maxOrNull() ?: 1.0
|
||||
val min = prices.minOrNull() ?: 0.0
|
||||
val range = if (max == min) 1.0 else max - min
|
||||
|
||||
val stepX = size.width / (prices.size - 1).coerceAtLeast(1)
|
||||
val points = prices.mapIndexed { i, p ->
|
||||
Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat())
|
||||
}
|
||||
|
||||
for (i in 0 until points.size - 1) {
|
||||
drawLine(
|
||||
color = if (prices.last() >= prices.first()) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
start = points[i],
|
||||
end = points[i + 1],
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/PeriodTrendCard.kt (신규/통합)
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.Canvas
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.geometry.Offset
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import model.CandleData
|
||||
//
|
||||
//@Composable
|
||||
//fun PeriodTrendCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
// val avgPrice = if (data.isEmpty()) "0"
|
||||
// else String.format("%,d", data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }.average().toLong())
|
||||
//
|
||||
// Card(modifier = modifier.height(80.dp), elevation = 2.dp, backgroundColor = Color.White) {
|
||||
// Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// // [좌측] 라벨 및 평균가
|
||||
// Column(modifier = Modifier.weight(0.4f)) {
|
||||
// Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
// Text(text = "${avgPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
// }
|
||||
//
|
||||
// // [우측] 간소화된 그래프 (Sparkline)
|
||||
// Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) {
|
||||
// if (data.isNotEmpty()) {
|
||||
// Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
// val prices = data.map { it.stck_prpr.toDoubleOrNull() ?: 0.0 }
|
||||
// val max = prices.maxOrNull() ?: 1.0
|
||||
// val min = prices.minOrNull() ?: 0.0
|
||||
// val range = if (max == min) 1.0 else max - min
|
||||
//
|
||||
// val stepX = size.width / (prices.size - 1).coerceAtLeast(1)
|
||||
// val points = prices.mapIndexed { i, p ->
|
||||
// Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat())
|
||||
// }
|
||||
//
|
||||
// for (i in 0 until points.size - 1) {
|
||||
// drawLine(
|
||||
// color = if (prices.last() >= prices.first()) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
// start = points[i],
|
||||
// end = points[i + 1],
|
||||
// strokeWidth = 2f
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,43 +1,43 @@
|
||||
// src/main/kotlin/ui/RealTimeTradeList.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.RealTimeTrade
|
||||
|
||||
@Composable
|
||||
fun RealTimeTradeList(tradeLogs: List<RealTimeTrade>) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [1] 리스트 헤더 영역
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFEEEEEE)) // 연한 회색 배경
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
// [2] 실제 데이터 리스트
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
// 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴)
|
||||
items(tradeLogs) { trade ->
|
||||
TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용
|
||||
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/RealTimeTradeList.kt
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items
|
||||
//import androidx.compose.material.Divider
|
||||
//import androidx.compose.material.Text
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.style.TextAlign
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import model.RealTimeTrade
|
||||
//
|
||||
//@Composable
|
||||
//fun RealTimeTradeList(tradeLogs: List<RealTimeTrade>) {
|
||||
// Column(modifier = Modifier.fillMaxSize()) {
|
||||
// // [1] 리스트 헤더 영역
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(Color(0xFFEEEEEE)) // 연한 회색 배경
|
||||
// .padding(vertical = 4.dp)
|
||||
// ) {
|
||||
// Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
// Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
// Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
// Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
|
||||
// }
|
||||
//
|
||||
// // [2] 실제 데이터 리스트
|
||||
// LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
// // 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴)
|
||||
// items(tradeLogs) { trade ->
|
||||
// TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용
|
||||
// Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,302 +1,302 @@
|
||||
package ui
|
||||
|
||||
|
||||
|
||||
import network.TradingDecision
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import model.CandleData
|
||||
import network.DartCodeManager
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
@Composable
|
||||
fun StockDetailSection(
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
holdingQuantity: String,
|
||||
isDomestic: Boolean,
|
||||
tradeService: KisTradeService,
|
||||
wsManager: KisWebSocketManager,
|
||||
onOrderSaved: (String) -> Unit,
|
||||
completeTradingDecision: TradingDecision?,
|
||||
min30 : MutableList<CandleData>,
|
||||
daySummary : MutableList<CandleData>,
|
||||
weekSummary : MutableList<CandleData>,
|
||||
monthSummary : MutableList<CandleData>,
|
||||
yearSummary : MutableList<CandleData>
|
||||
) {
|
||||
|
||||
// var openPrice by remember { mutableStateOf("0") }
|
||||
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var resultMessage by remember { mutableStateOf("") }
|
||||
var isSuccess by remember { mutableStateOf(true) }
|
||||
|
||||
|
||||
val todayOpen = remember(daySummary) {
|
||||
daySummary.lastOrNull()?.stck_oprc ?: "0"
|
||||
}
|
||||
val previousClose = remember(daySummary) {
|
||||
if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 이전 종목 코드를 기억하기 위한 상태
|
||||
var previousCode by remember { mutableStateOf("") }
|
||||
var lastPrice by remember { mutableStateOf("0") }
|
||||
|
||||
|
||||
// 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
|
||||
LaunchedEffect(stockCode) {
|
||||
if (stockCode.isEmpty()) return@LaunchedEffect
|
||||
|
||||
isLoading = true
|
||||
|
||||
// 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
|
||||
if (previousCode.isNotEmpty()) {
|
||||
wsManager.unsubscribeStock(previousCode)
|
||||
}
|
||||
wsManager.clearData()
|
||||
wsManager.subscribeStock(stockCode)
|
||||
previousCode = stockCode
|
||||
|
||||
|
||||
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
wsManager.onPriceUpdate = {tradeLog ->
|
||||
|
||||
|
||||
if (tradeLog.code.equals(stockCode)) {
|
||||
val code = tradeLog.code
|
||||
val price = tradeLog.price
|
||||
wsManager.tradeLogs.add(tradeLog)
|
||||
if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast()
|
||||
// println("code $code ,price $price")
|
||||
val currentPrice = price
|
||||
if (chartData.isNotEmpty() && currentPrice != "0") {
|
||||
val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
val lastCandle = chartData.last()
|
||||
|
||||
// 현재 시간(분 단위) 확인
|
||||
val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
|
||||
|
||||
if (lastCandle.stck_bsop_date != currentMinute) {
|
||||
// [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||
val newCandle = CandleData(
|
||||
stck_bsop_date = currentMinute,
|
||||
stck_oprc = currentPrice,
|
||||
stck_hgpr = currentPrice,
|
||||
stck_lwpr = currentPrice,
|
||||
stck_prpr = currentPrice,
|
||||
stck_cntg_hour = currentMinute,
|
||||
cntg_vol = "1",
|
||||
acml_tr_pbmn = "1",
|
||||
)
|
||||
// 최대 100개까지만 유지하여 성능 최적화
|
||||
chartData = (chartData + newCandle).takeLast(100)
|
||||
} else {
|
||||
// 같은 분 내에서는 기존 마지막 캔들만 업데이트
|
||||
val updatedCandle = lastCandle.copy(
|
||||
stck_prpr = currentPrice,
|
||||
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr,
|
||||
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr
|
||||
)
|
||||
chartData = chartData.dropLast(1) + updatedCandle
|
||||
}
|
||||
}
|
||||
lastPrice = currentPrice
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
launch {tradeService.fetchChartData(stockCode, isDomestic)
|
||||
.onSuccess { data ->
|
||||
// println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
chartData = data
|
||||
min30.clear()
|
||||
min30.addAll(chartData)
|
||||
}
|
||||
.onFailure { error ->
|
||||
// println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
chartData = emptyList()
|
||||
}
|
||||
}
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
|
||||
daySummary.clear()
|
||||
daySummary.addAll(it)
|
||||
}
|
||||
} // 최근 7일
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
|
||||
weekSummary.clear()
|
||||
weekSummary.addAll(it.takeLast(4))
|
||||
// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
|
||||
}
|
||||
} // 최근 4주
|
||||
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
|
||||
monthSummary.clear()
|
||||
monthSummary.addAll(it.takeLast(6))
|
||||
yearSummary.clear()
|
||||
yearSummary.addAll(it.takeLast(36))
|
||||
}
|
||||
}
|
||||
launch {
|
||||
DartCodeManager.getCorpCode(stockCode)?.let {
|
||||
it.stockName = stockName
|
||||
// NewsService.fetchAndIngestNews(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
|
||||
|
||||
// LaunchedEffect(latestPrice) {
|
||||
// println("latestPrice >>> $latestPrice")
|
||||
// if (chartData.isNotEmpty() && latestPrice != "0") {
|
||||
// val latestPrice = latestPrice ?: "0"
|
||||
// val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect
|
||||
// val lastCandle = chartData.last()
|
||||
//package ui
|
||||
//
|
||||
// // 현재 시간(분 단위) 확인
|
||||
// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
|
||||
//
|
||||
// if (lastCandle.stck_bsop_date != currentMinute) {
|
||||
// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||
// val newCandle = CandleData(
|
||||
// stck_bsop_date = currentMinute,
|
||||
// stck_oprc = latestPrice,
|
||||
// stck_hgpr = latestPrice,
|
||||
// stck_lwpr = latestPrice,
|
||||
// stck_prpr = latestPrice,
|
||||
// stck_cntg_hour = currentMinute,
|
||||
// cntg_vol = "1",
|
||||
// acml_tr_pbmn = "1",
|
||||
//
|
||||
//import network.TradingDecision
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.*
|
||||
//// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
//import androidx.compose.runtime.getValue
|
||||
//import androidx.compose.runtime.setValue
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import kotlinx.coroutines.coroutineScope
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.CandleData
|
||||
//import network.DartCodeManager
|
||||
//import network.KisTradeService
|
||||
//import network.KisWebSocketManager
|
||||
//import java.time.LocalTime
|
||||
//import java.time.format.DateTimeFormatter
|
||||
//import kotlin.collections.isNotEmpty
|
||||
//
|
||||
//@Composable
|
||||
//fun StockDetailSection(
|
||||
// stockCode: String,
|
||||
// stockName: String,
|
||||
// holdingQuantity: String,
|
||||
// isDomestic: Boolean,
|
||||
// tradeService: KisTradeService,
|
||||
// wsManager: KisWebSocketManager,
|
||||
// onOrderSaved: (String) -> Unit,
|
||||
// completeTradingDecision: TradingDecision?,
|
||||
// min30 : MutableList<CandleData>,
|
||||
// daySummary : MutableList<CandleData>,
|
||||
// weekSummary : MutableList<CandleData>,
|
||||
// monthSummary : MutableList<CandleData>,
|
||||
// yearSummary : MutableList<CandleData>
|
||||
//) {
|
||||
//
|
||||
//// var openPrice by remember { mutableStateOf("0") }
|
||||
// var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
// var isLoading by remember { mutableStateOf(false) }
|
||||
// var resultMessage by remember { mutableStateOf("") }
|
||||
// var isSuccess by remember { mutableStateOf(true) }
|
||||
//
|
||||
//
|
||||
// val todayOpen = remember(daySummary) {
|
||||
// daySummary.lastOrNull()?.stck_oprc ?: "0"
|
||||
// }
|
||||
// val previousClose = remember(daySummary) {
|
||||
// if (daySummary.size >= 2) daySummary[daySummary.size - 2].stck_prpr else "0"
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// // 이전 종목 코드를 기억하기 위한 상태
|
||||
// var previousCode by remember { mutableStateOf("") }
|
||||
// var lastPrice by remember { mutableStateOf("0") }
|
||||
//
|
||||
//
|
||||
// // 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
|
||||
// LaunchedEffect(stockCode) {
|
||||
// if (stockCode.isEmpty()) return@LaunchedEffect
|
||||
//
|
||||
// isLoading = true
|
||||
//
|
||||
// // 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
|
||||
// if (previousCode.isNotEmpty()) {
|
||||
// wsManager.unsubscribeStock(previousCode)
|
||||
// }
|
||||
// wsManager.clearData()
|
||||
// wsManager.subscribeStock(stockCode)
|
||||
// previousCode = stockCode
|
||||
//
|
||||
//
|
||||
// // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
|
||||
//
|
||||
// coroutineScope {
|
||||
// launch {
|
||||
// wsManager.onPriceUpdate = {tradeLog ->
|
||||
//
|
||||
//
|
||||
// if (tradeLog.code.equals(stockCode)) {
|
||||
// val code = tradeLog.code
|
||||
// val price = tradeLog.price
|
||||
// wsManager.tradeLogs.add(tradeLog)
|
||||
// if (wsManager.tradeLogs.size > 50) wsManager.tradeLogs.removeLast()
|
||||
//// println("code $code ,price $price")
|
||||
// val currentPrice = price
|
||||
// if (chartData.isNotEmpty() && currentPrice != "0") {
|
||||
// val priceDouble = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
// val lastCandle = chartData.last()
|
||||
//
|
||||
// // 현재 시간(분 단위) 확인
|
||||
// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
|
||||
//
|
||||
// if (lastCandle.stck_bsop_date != currentMinute) {
|
||||
// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||
// val newCandle = CandleData(
|
||||
// stck_bsop_date = currentMinute,
|
||||
// stck_oprc = currentPrice,
|
||||
// stck_hgpr = currentPrice,
|
||||
// stck_lwpr = currentPrice,
|
||||
// stck_prpr = currentPrice,
|
||||
// stck_cntg_hour = currentMinute,
|
||||
// cntg_vol = "1",
|
||||
// acml_tr_pbmn = "1",
|
||||
// )
|
||||
// // 최대 100개까지만 유지하여 성능 최적화
|
||||
// chartData = (chartData + newCandle).takeLast(100)
|
||||
// } else {
|
||||
// // 같은 분 내에서는 기존 마지막 캔들만 업데이트
|
||||
// val updatedCandle = lastCandle.copy(
|
||||
// stck_prpr = currentPrice,
|
||||
// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) currentPrice else lastCandle.stck_hgpr,
|
||||
// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) currentPrice else lastCandle.stck_lwpr
|
||||
// )
|
||||
// chartData = chartData.dropLast(1) + updatedCandle
|
||||
// }
|
||||
// }
|
||||
// lastPrice = currentPrice
|
||||
// }
|
||||
//
|
||||
// }
|
||||
// }
|
||||
// launch {tradeService.fetchChartData(stockCode, isDomestic)
|
||||
// .onSuccess { data ->
|
||||
//// println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력
|
||||
// chartData = data
|
||||
// min30.clear()
|
||||
// min30.addAll(chartData)
|
||||
// }
|
||||
// .onFailure { error ->
|
||||
//// println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
|
||||
// chartData = emptyList()
|
||||
// }
|
||||
// }
|
||||
// launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
|
||||
// daySummary.clear()
|
||||
// daySummary.addAll(it)
|
||||
// }
|
||||
// } // 최근 7일
|
||||
// launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
|
||||
// weekSummary.clear()
|
||||
// weekSummary.addAll(it.takeLast(4))
|
||||
//// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
|
||||
// }
|
||||
// } // 최근 4주
|
||||
// launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
|
||||
// monthSummary.clear()
|
||||
// monthSummary.addAll(it.takeLast(6))
|
||||
// yearSummary.clear()
|
||||
// yearSummary.addAll(it.takeLast(36))
|
||||
// }
|
||||
// }
|
||||
// launch {
|
||||
// DartCodeManager.getCorpCode(stockCode)?.let {
|
||||
// it.stockName = stockName
|
||||
//// NewsService.fetchAndIngestNews(it)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// isLoading = false
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
//// LaunchedEffect(latestPrice) {
|
||||
//// println("latestPrice >>> $latestPrice")
|
||||
//// if (chartData.isNotEmpty() && latestPrice != "0") {
|
||||
//// val latestPrice = latestPrice ?: "0"
|
||||
//// val priceDouble = latestPrice?.replace(",", "")?.toDoubleOrNull() ?: return@LaunchedEffect
|
||||
//// val lastCandle = chartData.last()
|
||||
////
|
||||
//// // 현재 시간(분 단위) 확인
|
||||
//// val currentMinute = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmm00"))
|
||||
////
|
||||
//// if (lastCandle.stck_bsop_date != currentMinute) {
|
||||
//// // [개선] 시간이 바뀌었으면 새로운 캔들 추가 (차트가 밀려나는 효과)
|
||||
//// val newCandle = CandleData(
|
||||
//// stck_bsop_date = currentMinute,
|
||||
//// stck_oprc = latestPrice,
|
||||
//// stck_hgpr = latestPrice,
|
||||
//// stck_lwpr = latestPrice,
|
||||
//// stck_prpr = latestPrice,
|
||||
//// stck_cntg_hour = currentMinute,
|
||||
//// cntg_vol = "1",
|
||||
//// acml_tr_pbmn = "1",
|
||||
//// )
|
||||
//// // 최대 100개까지만 유지하여 성능 최적화
|
||||
//// chartData = (chartData + newCandle).takeLast(100)
|
||||
//// } else {
|
||||
//// // 같은 분 내에서는 기존 마지막 캔들만 업데이트
|
||||
//// val updatedCandle = lastCandle.copy(
|
||||
//// stck_prpr = latestPrice,
|
||||
//// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
//// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
//// )
|
||||
//// chartData = chartData.dropLast(1) + updatedCandle
|
||||
//// }
|
||||
//// }
|
||||
//// }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// // [상단] 종목명 및 상태 메시지
|
||||
// Row(
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// StockHeader(
|
||||
// name = stockName,
|
||||
// code = stockCode,
|
||||
// isDomestic = isDomestic,
|
||||
// previousClose = previousClose,
|
||||
// openPrice = lastPrice,
|
||||
// resultMessage = resultMessage,
|
||||
// resultMessageClear = {resultMessage = ""},
|
||||
// isSuccess = isSuccess
|
||||
// )
|
||||
//
|
||||
// // 실시간 가격 표시 (WebSocket 데이터)
|
||||
// Column(horizontalAlignment = Alignment.End) {
|
||||
// Text(
|
||||
// text = "${lastPrice} 원",
|
||||
// style = MaterialTheme.typography.h4,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = if (lastPrice?.contains("-") ?: false) Color.Blue else Color.Red
|
||||
// )
|
||||
// // 최대 100개까지만 유지하여 성능 최적화
|
||||
// chartData = (chartData + newCandle).takeLast(100)
|
||||
// Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray)
|
||||
// }
|
||||
// }
|
||||
// // 통합된 트렌드 카드 배치
|
||||
// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// PeriodTrendCard("7일", daySummary, Modifier.weight(1f))
|
||||
// PeriodTrendCard("4주", weekSummary, Modifier.weight(1f))
|
||||
// PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f))
|
||||
// PeriodTrendCard("3년", yearSummary, Modifier.weight(1f))
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
// // [중앙] 캔들 차트 (Card 내부)
|
||||
// Card(
|
||||
// modifier = Modifier.fillMaxWidth().height(320.dp),
|
||||
// backgroundColor = Color(0xFF121212)
|
||||
// ) {
|
||||
// if (isLoading) {
|
||||
// Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
|
||||
// } else {
|
||||
// // 같은 분 내에서는 기존 마지막 캔들만 업데이트
|
||||
// val updatedCandle = lastCandle.copy(
|
||||
// stck_prpr = latestPrice,
|
||||
// stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
|
||||
// stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
|
||||
// CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
//
|
||||
//
|
||||
//
|
||||
// // [하단] 실시간 체결 내역 및 주문 섹션
|
||||
// Row(modifier = Modifier.weight(1f)) {
|
||||
// // 실시간 체결 리스트
|
||||
// Column(modifier = Modifier.weight(1f)) {
|
||||
// Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
// RealTimeTradeList(wsManager.tradeLogs)
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.width(12.dp))
|
||||
//
|
||||
// // 주문 섹션 (인자 간소화)
|
||||
// Column(modifier = Modifier.weight(0.6f)) {
|
||||
// IntegratedOrderSection(
|
||||
// stockCode = stockCode,
|
||||
// stockName = stockName,
|
||||
// isDomestic = isDomestic,
|
||||
// currentPrice = lastPrice,
|
||||
// holdingQuantity = holdingQuantity,
|
||||
// tradeService = tradeService,
|
||||
// onOrderSaved = onOrderSaved,
|
||||
// onOrderResult = { msg, success ->
|
||||
// resultMessage = msg
|
||||
// isSuccess = success
|
||||
// },
|
||||
// completeTradingDecision
|
||||
// )
|
||||
// chartData = chartData.dropLast(1) + updatedCandle
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// [상단] 종목명 및 상태 메시지
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StockHeader(
|
||||
name = stockName,
|
||||
code = stockCode,
|
||||
isDomestic = isDomestic,
|
||||
previousClose = previousClose,
|
||||
openPrice = lastPrice,
|
||||
resultMessage = resultMessage,
|
||||
resultMessageClear = {resultMessage = ""},
|
||||
isSuccess = isSuccess
|
||||
)
|
||||
|
||||
// 실시간 가격 표시 (WebSocket 데이터)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "${lastPrice} 원",
|
||||
style = MaterialTheme.typography.h4,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (lastPrice?.contains("-") ?: false) Color.Blue else Color.Red
|
||||
)
|
||||
Text("실시간 체결가", style = MaterialTheme.typography.caption, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
// 통합된 트렌드 카드 배치
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PeriodTrendCard("7일", daySummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("4주", weekSummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("6개월", monthSummary, Modifier.weight(1f))
|
||||
PeriodTrendCard("3년", yearSummary, Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// [중앙] 캔들 차트 (Card 내부)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(320.dp),
|
||||
backgroundColor = Color(0xFF121212)
|
||||
) {
|
||||
if (isLoading) {
|
||||
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
|
||||
} else {
|
||||
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
|
||||
|
||||
// [하단] 실시간 체결 내역 및 주문 섹션
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
// 실시간 체결 리스트
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
RealTimeTradeList(wsManager.tradeLogs)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 주문 섹션 (인자 간소화)
|
||||
Column(modifier = Modifier.weight(0.6f)) {
|
||||
IntegratedOrderSection(
|
||||
stockCode = stockCode,
|
||||
stockName = stockName,
|
||||
isDomestic = isDomestic,
|
||||
currentPrice = lastPrice,
|
||||
holdingQuantity = holdingQuantity,
|
||||
tradeService = tradeService,
|
||||
onOrderSaved = onOrderSaved,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
},
|
||||
completeTradingDecision
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun PeriodSummaryCard(label: String, avgPrice: String, modifier: Modifier = Modifier) {
|
||||
// Card(modifier = modifier, elevation = 2.dp, backgroundColor = Color.White) {
|
||||
// Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Text(label, fontSize = 10.sp, color = Color.Gray)
|
||||
// Text(text = "${avgPrice}원", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.Black)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -1,74 +1,74 @@
|
||||
// src/main/kotlin/ui/StockHeader.kt
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun StockHeader(
|
||||
name: String,
|
||||
code: String,
|
||||
isDomestic: Boolean,
|
||||
previousClose: String, // 추가: 전일 종가
|
||||
openPrice: String, // 추가: 금일 시가
|
||||
resultMessage: String,
|
||||
resultMessageClear : ()->Unit,
|
||||
isSuccess: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.wrapContentWidth()) {
|
||||
// [1] 알림 메시지 영역 (기존 동일)
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
onClick = {
|
||||
resultMessageClear.invoke()
|
||||
}
|
||||
) {
|
||||
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
// [2] 종목명 및 정보
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = "($code)", color = Color.Gray)
|
||||
}
|
||||
|
||||
// [3] 전일 종가 및 시가 정보 행 추가
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
PriceSummaryItem("전일 종가", previousClose)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
PriceSummaryItem("금일 시가", openPrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PriceSummaryItem(label: String, price: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = label, fontSize = 11.sp, color = Color.Gray)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price
|
||||
Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
//// src/main/kotlin/ui/StockHeader.kt
|
||||
//package ui
|
||||
//
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.Composable
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.text.style.TextAlign
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//
|
||||
//@OptIn(ExperimentalMaterialApi::class)
|
||||
//@Composable
|
||||
//fun StockHeader(
|
||||
// name: String,
|
||||
// code: String,
|
||||
// isDomestic: Boolean,
|
||||
// previousClose: String, // 추가: 전일 종가
|
||||
// openPrice: String, // 추가: 금일 시가
|
||||
// resultMessage: String,
|
||||
// resultMessageClear : ()->Unit,
|
||||
// isSuccess: Boolean
|
||||
//) {
|
||||
// Column(modifier = Modifier.wrapContentWidth()) {
|
||||
// // [1] 알림 메시지 영역 (기존 동일)
|
||||
// if (resultMessage.isNotEmpty()) {
|
||||
// Surface(
|
||||
// color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
// modifier = Modifier.padding(bottom = 8.dp),
|
||||
// shape = RoundedCornerShape(4.dp),
|
||||
// onClick = {
|
||||
// resultMessageClear.invoke()
|
||||
// }
|
||||
// ) {
|
||||
// Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // [2] 종목명 및 정보
|
||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Surface(
|
||||
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF),
|
||||
// shape = RoundedCornerShape(4.dp)
|
||||
// ) {
|
||||
// Text(text = if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
|
||||
// }
|
||||
// Spacer(modifier = Modifier.width(8.dp))
|
||||
// Text(text = name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
|
||||
// Spacer(modifier = Modifier.width(6.dp))
|
||||
// Text(text = "($code)", color = Color.Gray)
|
||||
// }
|
||||
//
|
||||
// // [3] 전일 종가 및 시가 정보 행 추가
|
||||
// Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
// PriceSummaryItem("전일 종가", previousClose)
|
||||
// Spacer(modifier = Modifier.width(16.dp))
|
||||
// PriceSummaryItem("금일 시가", openPrice)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun PriceSummaryItem(label: String, price: String) {
|
||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(text = label, fontSize = 11.sp, color = Color.Gray)
|
||||
// Spacer(modifier = Modifier.width(4.dp))
|
||||
// val formattedPrice = price.toLongOrNull()?.let { String.format("%,d", it) } ?: price
|
||||
// Text(text = "${formattedPrice}원", fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
// }
|
||||
//}
|
||||
@ -1,63 +1,63 @@
|
||||
package ui
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.RankingStock
|
||||
import model.RealTimeTrade
|
||||
import model.StockHolding
|
||||
import model.TradeType
|
||||
import network.KisTradeService
|
||||
import util.MarketUtil
|
||||
|
||||
@Composable
|
||||
fun TradeLogRow(trade: RealTimeTrade) {
|
||||
val color = when (trade.type) {
|
||||
TradeType.BUY -> Color(0xFFE03E2D)
|
||||
TradeType.SELL -> Color(0xFF0E62CF)
|
||||
else -> Color.DarkGray
|
||||
}
|
||||
|
||||
// 대량 체결(예: 1000주 이상) 시 연한 배경색 강조
|
||||
val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000
|
||||
val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center)
|
||||
Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End)
|
||||
Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End)
|
||||
Text(
|
||||
text = trade.volume,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal,
|
||||
color = color,
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
//package ui
|
||||
//
|
||||
//
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.*
|
||||
//
|
||||
//import io.ktor.client.engine.cio.CIO
|
||||
//// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
//import androidx.compose.runtime.getValue
|
||||
//import androidx.compose.runtime.setValue
|
||||
//import androidx.compose.ui.Alignment
|
||||
//import androidx.compose.ui.Modifier
|
||||
//import androidx.compose.ui.graphics.Color
|
||||
//import androidx.compose.ui.text.font.FontWeight
|
||||
//import androidx.compose.ui.text.style.TextAlign
|
||||
//import androidx.compose.ui.text.style.TextOverflow
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.AppConfig
|
||||
//import model.BalanceSummary
|
||||
//import model.RankingStock
|
||||
//import model.RealTimeTrade
|
||||
//import model.StockHolding
|
||||
//import model.TradeType
|
||||
//import network.KisTradeService
|
||||
//import util.MarketUtil
|
||||
//
|
||||
//@Composable
|
||||
//fun TradeLogRow(trade: RealTimeTrade) {
|
||||
// val color = when (trade.type) {
|
||||
// TradeType.BUY -> Color(0xFFE03E2D)
|
||||
// TradeType.SELL -> Color(0xFF0E62CF)
|
||||
// else -> Color.DarkGray
|
||||
// }
|
||||
//
|
||||
// // 대량 체결(예: 1000주 이상) 시 연한 배경색 강조
|
||||
// val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000
|
||||
// val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center)
|
||||
// Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End)
|
||||
// Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End)
|
||||
// Text(
|
||||
// text = trade.volume,
|
||||
// modifier = Modifier.weight(1f),
|
||||
// fontSize = 12.sp,
|
||||
// fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal,
|
||||
// color = color,
|
||||
// textAlign = TextAlign.End
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
@ -395,3 +395,33 @@ fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? =
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
fun findLongestCommonSubstring(s1: String, s2: String): String {
|
||||
if (s1.isEmpty() || s2.isEmpty()) return ""
|
||||
|
||||
var longest = ""
|
||||
// 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
|
||||
val reference = if (s1.length <= s2.length) s1 else s2
|
||||
val target = if (s1.length <= s2.length) s2 else s1
|
||||
|
||||
for (i in reference.indices) {
|
||||
for (j in (i + longest.length + 1)..reference.length) {
|
||||
val sub = reference.substring(i, j)
|
||||
if (target.contains(sub)) {
|
||||
if (sub.length > longest.length) {
|
||||
longest = sub
|
||||
}
|
||||
} else {
|
||||
// target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
fun getRemaining(original: String, common: String): String {
|
||||
if (common.isEmpty()) return original
|
||||
// 가장 처음 발견되는 공통 문자열을 한 번만 제거
|
||||
return original.replaceFirst(common, "").trim()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user