빨라져라

This commit is contained in:
lunaticbum 2026-04-07 17:32:21 +09:00
parent a700d54dfe
commit 9172cca791
24 changed files with 3337 additions and 3253 deletions

View File

@ -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()

View 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()
}
}

View 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
}
}

View 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() }
}

View 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()
}
}

View File

@ -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}")
}

View File

@ -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
)

View File

@ -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,

View File

@ -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") ?: ""

View File

@ -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
// )
// }
// }
// }
//}

View File

@ -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)
// }
// }
//}

View File

@ -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) }
// )
// }
// }
// }
//}

View File

@ -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
// )
// }
// }
// }
//}

View File

@ -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)
// )
// }
// }
//}

View File

@ -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

View File

@ -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)
// }
// }
// }
// }
//}

View File

@ -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
// )
// }
// }
//}

View File

@ -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
// )
// }
// }
// }
// }
// }
// }
//}

View File

@ -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)
// }
// }
// }
//}

View File

@ -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)
// }
// }
//}

View File

@ -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)
// }
//}

View File

@ -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
// )
// }
//}

View File

@ -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()
}