From 1787b7249961ed5c83494c3d4cf9470584c8653d Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 4 Feb 2026 14:52:09 +0900 Subject: [PATCH] ... --- src/main/kotlin/Main.kt | 2 + src/main/kotlin/model/AppConfig.kt | 3 + src/main/kotlin/network/RagService.kt | 9 +- src/main/kotlin/service/AutoTradingManager.kt | 104 ++++++++++++------ src/main/kotlin/service/DynamicNewsScraper.kt | 85 +++++++++----- .../kotlin/service/SystemSleepPreventer.kt | 35 ++++++ src/main/kotlin/ui/AiAnalysisView.kt | 5 +- src/main/kotlin/ui/DashboardScreen.kt | 72 +++++++++--- src/main/kotlin/ui/IntegratedOrderSection.kt | 65 ++++++++--- src/main/kotlin/ui/StockDetailArea.kt | 34 +++--- src/main/kotlin/util/MarketUtil.kt | 12 ++ 11 files changed, 314 insertions(+), 112 deletions(-) create mode 100644 src/main/kotlin/service/SystemSleepPreventer.kt diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 76ca91a..b91843c 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -24,6 +24,7 @@ import network.DartCodeManager import service.LlamaServerManager import network.NewsService import org.jetbrains.exposed.sql.selectAll +import service.SystemSleepPreventer import ui.DashboardScreen import ui.SettingsScreen @@ -31,6 +32,7 @@ import ui.SettingsScreen enum class AppScreen { Settings, Dashboard } fun main() = application { + SystemSleepPreventer.start() LaunchedEffect(Unit) { // NewsService나 KisTradeService에서 사용하는 client를 전달 DartCodeManager.updateCorpCodes(HttpClient(CIO) { diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index 3cf1e3e..d5ac022 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -2,6 +2,9 @@ package model import java.time.LocalDateTime +const val feesAndTaxRate = 0.3 +const val minimumNetProfit = 0.8 + data class AppConfig( // [DB 저장 데이터] // 실전 3종 diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 2e515a4..3126b3c 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -127,7 +127,7 @@ object RagService { } } - suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) { + suspend fun processStock(technicalAnalyzer: TechnicalAnalyzer,stockName: String,stockCode: String,result : TradingDecisionCallback) { // 1. 10분간의 데이터 가져오기 (API 호출) coroutineScope { try { @@ -146,7 +146,7 @@ object RagService { tradingDecision.financialData = financialDataDeferred.await() result(tradingDecision, false) - tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() + tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() result(tradingDecision, false) val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" @@ -333,6 +333,11 @@ class TradingDecision { var newsContext : String? = null var financialData : String? = null + + fun shortPossible() = + listOf(ultraShortScore, + shortTermScore).average() + fun profitPossible() = listOf(ultraShortScore, shortTermScore, diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index b6fc307..c953973 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -40,7 +40,7 @@ object AutoTradingManager { try { // 1. [체크] 현재 잔고 및 보유 종목 조회 val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() - val myHoldings = balanceResult?.holdings?.map { it.code }?.toSet() ?: emptySet() + val myHoldings = balanceResult?.holdings?.filter { it.quantity.toInt() > 0 }?.map { it.code }?.toSet() ?: emptySet() val myCash = balanceResult?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L println("💰 보유 현금: ${String.format("%,d", myCash)}원 | 보유 종목 수: ${myHoldings.size}") @@ -49,31 +49,42 @@ object AutoTradingManager { // 1. 랭킹 데이터 가져오기 (비동기) val volRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) } val riseRankDeferred = async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) } - +// 거래대금(Amount) 상위 추가 + val amountRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) } + val volumePowerDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) } val volList = volRankDeferred.await() val riseList = riseRankDeferred.await() + val amountList = amountRankDeferred.await() + val volumeList = volumePowerDeferred.await() +// (C) 거래대금 상위 종목 필터링 (시장의 주도주) + val amountCandidates = amountList + .filter { stock -> + val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 + rate in 1.0..15.0 // 너무 과열되지 않은 주도주 + } -// [수정] 2. 의미 있는 후보군 선정 (단순 상위 15개가 아님) - -// (A) 거래량 상위 종목 중: 현재가 기준 등락률이 0% ~ 20% 사이인 것만 필터링 -> 상위 10개 val volCandidates = volList .filter { stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 - rate in 0.0..20.0 // 0% 초과 20% 이하 + rate in 1.0..18.0 // 0% 초과 20% 이하 } - .take(10) + // (B) 상승률 상위 종목 중: 너무 급등한(20% 초과) 종목은 제외하고, 적당히 오르고 있는 종목만 필터링 -> 상위 10개 // 보통 상승률 랭킹은 상한가(30%)부터 내려오므로, 앞쪽의 급등주를 건너뛰어야 함 val riseCandidates = riseList .filter { stock -> val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 - rate in 3.0..20.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간 + rate in 2.0..18.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간 } - .take(10) -// 3. 두 리스트 합치기 (중복 제거) - val candidates = (volCandidates + riseCandidates).distinctBy { it.code } + val volumeCandidates = volumeList .filter { stock -> + val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 + rate in 1.0..18.0 // 최소 3% 이상은 올라야 의미 있음, 20% 이하는 안전 구간 + } + +// 3. 리스트 합치기 (중복 제거) + val candidates = (volCandidates + riseCandidates + amountCandidates + volumeCandidates).distinctBy { it.code } println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...") @@ -84,14 +95,19 @@ object AutoTradingManager { val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0 // [조건 2] 최소 1주 매수 가능 여부 - if (currentPrice > myCash) return@forEach - + if (currentPrice > myCash || currentPrice > 5000) return@forEach + callback(TradingDecision().apply { + this.stockCode = stock.code + this.confidence = -1.0 + this.stockName = stock.name + }, false) // 3. 일봉 데이터 조회 (필터링 용도 + TechnicalAnalyzer 입력용) val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true) val dailyData = dailyResult.getOrNull() val todayCandle = dailyData?.lastOrNull() if (dailyData != null && todayCandle != null) { + val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0 val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0 @@ -113,14 +129,14 @@ object AutoTradingManager { val monthlyData = monthDef.await() // TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수) - TechnicalAnalyzer.clear() - TechnicalAnalyzer.daily = dailyData - TechnicalAnalyzer.weekly = weeklyData - TechnicalAnalyzer.monthly = monthlyData - TechnicalAnalyzer.min30 = min30Data + val t = TechnicalAnalyzer() + t.daily = dailyData + t.weekly = weeklyData + t.monthly = monthlyData + t.min30 = min30Data // 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지) - RagService.processStock(stock.name, stock.code) { decision, isSuccess -> + RagService.processStock(t,stock.name, stock.code) { decision, isSuccess -> if (decision != null) { decision.stockName = stock.name decision.currentPrice = current // 차트에서 확인한 최신 현재가 주입 @@ -133,13 +149,31 @@ object AutoTradingManager { } } } - delay(100) // 종목 간 API 호출 간격 + delay(300) // 종목 간 API 호출 간격 } - println("💤 사이클 종료. 5분 대기...") + + + // --- 10초 주기 로그 대기 로직 시작 --- + val waitMinutes = 3 + val totalWaitMillis = waitMinutes * 60 * 1000L + val tickMillis = 10 * 1000L + var currentWait = 0L + println("💤 사이클 종료. ${waitMinutes}분 대기...") + println("✅ 이번 사이클 분석 완료.") + while (currentWait < totalWaitMillis && discoveryJob?.isActive == true) { + delay(tickMillis) + currentWait += tickMillis + val leftSec = (totalWaitMillis - currentWait) / 1000 + // 1분 단위 혹은 10초 단위로 자유롭게 로그 조절 가능 + if (leftSec % 30 == 0L || leftSec <= 30) { + println("📡 [AutoTrading] 시스템 정상 작동 중... (다음 분석 ${leftSec}초 전)") + } + } + } catch (e: Exception) { println("⚠️ 루프 오류: ${e.message}") + delay(10000) // 오류 발생 시 10초 후 재시도 } - delay(5 * 60 * 1000) // 5분 } } } @@ -152,9 +186,9 @@ object AutoTradingManager { } // 기존 단일 종목 추가 로직 (유지) - fun addStock(stockName: String, stockCode: String, result: TradingDecisionCallback) { + fun addStock(technicalAnalyzer : TechnicalAnalyzer,stockName: String, stockCode: String, result: TradingDecisionCallback) { scope.launch { - RagService.processStock(stockName, stockCode, result) + RagService.processStock(technicalAnalyzer,stockName, stockCode, result) } } @@ -166,7 +200,7 @@ object AutoTradingManager { } } -object TechnicalAnalyzer { +class TechnicalAnalyzer { var monthly: List = emptyList() var weekly: List = emptyList() var daily: List = emptyList() @@ -184,21 +218,21 @@ object TechnicalAnalyzer { ): InvestmentScores { // 1. 초단기 (분봉 + 에너지 지표 위주) - val ultra = (TechnicalAnalyzer.calculateMFI(min30, 14) * 0.4 + - TechnicalAnalyzer.calculateStochastic(min30) * 0.3 + - (if(TechnicalAnalyzer.calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt() + val ultra = (calculateMFI(min30, 14) * 0.4 + + calculateStochastic(min30) * 0.3 + + (if(calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt() // 2. 단기 (일봉 추세 + OBV 에너지) - val short = (TechnicalAnalyzer.calculateRSI(daily) * 0.3 + - (if(TechnicalAnalyzer.calculateOBV(daily) > 0) 40 else 10) + - (if(TechnicalAnalyzer.calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt() + val short = (calculateRSI(daily) * 0.3 + + (if(calculateOBV(daily) > 0) 40 else 10) + + (if(calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt() // 3. 중기 (주봉 + 재무 점수 혼합) - val mid = (if(TechnicalAnalyzer.calculateChange(weekly) > 0) 40 else 10) + + val mid = (if(calculateChange(weekly) > 0) 40 else 10) + (financialScore * 0.6).toInt() // 4. 장기 (월봉 + 섹터/기업 펀더멘털) - val long = (if(TechnicalAnalyzer.calculateChange(monthly) > 0) 50 else 0) + + val long = (if(calculateChange(monthly) > 0) 50 else 0) + (financialScore * 0.5).toInt() return InvestmentScores( @@ -390,8 +424,8 @@ class ScalpingAnalyzer { 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 = -0.5 - private const val DEFAULT_TP_PCT = 1.0 + private const val DEFAULT_SL_PCT = -1.5 + private const val DEFAULT_TP_PCT = 1.5 private const val HIGH_SCORE_THRESHOLD = 80 } diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt index f2ce7f8..fd6d0aa 100644 --- a/src/main/kotlin/service/DynamicNewsScraper.kt +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -3,6 +3,7 @@ package service import com.microsoft.playwright.Playwright import com.microsoft.playwright.BrowserType import com.microsoft.playwright.Page +import com.microsoft.playwright.options.LoadState import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -92,33 +93,58 @@ object DynamicNewsScraper { } suspend fun fetchFullContent(url: String): String { + // browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다. val context = browser.newContext() - val page = context.newPage() - delay(Random.nextInt(1000).toLong()) return try { - // 1. 페이지 이동 및 네트워크 유휴 상태까지 대기 - blockUnnecessaryResources(page) - page.navigate(url) -// println(url) - page.waitForLoadState() + context.use { ctx -> + ctx.newPage().use { page -> + delay(Random.nextInt(1000).toLong()) + // 1. 리스너 설정 시 예외 처리 강화 + blockUnnecessaryResources(page) - var finded = cleanText(extractSmartContentWithLineFilter(page)) - println("finded : $finded") - finded + // 2. 타임아웃을 설정하여 무한 대기 방지 + val options = Page.NavigateOptions().setTimeout(30000.0) + page.navigate(url, options) + + // 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기 + page.waitForLoadState(LoadState.LOAD) + + val content = cleanText(extractSmartContentWithLineFilter(page)) + + // 4. 명시적으로 route를 해제하여 close 시 발생할 수 있는 리스너 충돌 방지 + page.unroute("**/*") + + content + } + } } catch (e: Exception) { - println("❌ [Playwright] 스크래핑 실패: ${e.message}") + println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}") "" } finally { - page.close() - context.close() + // use 블록이 자원을 닫으려 할 때 발생하는 오류는 내부적으로 처리되거나 무시되도록 유도 } } private fun blockUnnecessaryResources(page: Page) { // 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단 - page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route -> - route.abort() + page.route("**/*") { route -> + try { + val req = route.request() + if (req != null) { + val type = req.resourceType() + if (type == "image" || type == "font" || type == "stylesheet") { + route.abort() + } else { + route.resume() + } + } else { + // request가 이미 null이면 처리를 포기 + route.resume() + } + } catch (e: Exception) { + + } } } @@ -138,16 +164,25 @@ object SafeScraper { urls.map { item -> async { if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) { - semaphore.withPermit { - RagService.ingestWithChunking( - text = DynamicNewsScraper.fetchFullContent(item.originallink), - newsLink = item.originallink, - pubDate = item.pubDate, - stockCode = corpInfo.stockCode, - corpName = corpInfo.cName, - corpCode = corpInfo.cCode, - stcokName = corpInfo.stockName - ) + try { + semaphore.withPermit { + try { + RagService.ingestWithChunking( + text = DynamicNewsScraper.fetchFullContent(item.originallink), + newsLink = item.originallink, + pubDate = item.pubDate, + stockCode = corpInfo.stockCode, + corpName = corpInfo.cName, + corpCode = corpInfo.cCode, + stcokName = corpInfo.stockName + ) + }catch (e: Exception) { + println("${e.message}") + } + + } + }catch (e: Exception) { + println("${e.message}") } println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링") } else { diff --git a/src/main/kotlin/service/SystemSleepPreventer.kt b/src/main/kotlin/service/SystemSleepPreventer.kt new file mode 100644 index 0000000..2b6f117 --- /dev/null +++ b/src/main/kotlin/service/SystemSleepPreventer.kt @@ -0,0 +1,35 @@ +package service + +import java.util.concurrent.TimeUnit + +object SystemSleepPreventer { + private var process: Process? = null + + /** + * 맥의 절전 모드 및 디스플레이 취침을 방지하는 명령 실행 + */ + fun start() { + if (process?.isAlive == true) return + + try { + // -i: 시스템 절전 방지, -d: 디스플레이 취침 방지, -m: 디스크 유휴 상태 방지 + val command = listOf("caffeinate", "-i", "-d", "-m") + process = ProcessBuilder(command).start() + println("☕ [System] caffeinate 실행됨: 앱이 켜져 있는 동안 절전 모드가 방지됩니다.") + } catch (e: Exception) { + println("⚠️ [System] caffeinate 실행 실패: ${e.message}") + } + } + + /** + * 앱 종료 시 프로세스 함께 종료 + */ + fun stop() { + process?.destroy() + // 프로세스가 강제 종료되지 않을 경우를 대비해 0.5초 대기 후 강제 종료 + if (process?.waitFor(500, TimeUnit.MILLISECONDS) == false) { + process?.destroyForcibly() + } + println("🛑 [System] caffeinate 종료됨: 시스템 절전 설정이 정상화됩니다.") + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt index 5048d4f..45de08a 100644 --- a/src/main/kotlin/ui/AiAnalysisView.kt +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -26,10 +26,11 @@ 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(stockCode:String,stockName: String, currentPrice: String, trades: List, tradingDecisionCallback: TradingDecisionCallback) { +fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List, tradingDecisionCallback: TradingDecisionCallback) { var aiOpinion by remember { mutableStateOf("분석 대기 중...") } var code by remember(stockCode) { aiOpinion = "" @@ -67,7 +68,7 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra scope.launch { isAnalyzing = true try { - AutoTradingManager.addStock(stockName,stockCode) { decision,success -> + AutoTradingManager.addStock(technicalAnalyzer,stockName,stockCode) { decision,success -> aiOpinion = decision.toString() isAnalyzing = !success tradingDecisionCallback.invoke(decision,success) diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index 53d2ea7..2edc48f 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -12,12 +12,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import model.CandleData import model.ExecutionData import model.KisSession import model.StockBasicInfo +import model.feesAndTaxRate +import model.minimumNetProfit import network.KisTradeService import network.KisWebSocketManager import service.AutoTradingManager +import service.TechnicalAnalyzer +import util.MarketUtil +import kotlin.collections.mutableListOf @Composable fun DashboardScreen() { @@ -33,17 +39,33 @@ fun DashboardScreen() { var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 var completeTradingDecision by remember { mutableStateOf(null) } // 단순 종목 선택 시 + + var min30 by remember { mutableStateOf>(mutableListOf()) } + var daySummary by remember { mutableStateOf>(mutableListOf()) } + var weekSummary by remember { mutableStateOf>(mutableListOf()) } + var monthSummary by remember { mutableStateOf>(mutableListOf()) } + var yearSummary by remember { mutableStateOf>(mutableListOf()) } + DisposableEffect(Unit) { // 1. 화면 진입 시: 자동 발굴 루프 시작 // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess -> - if (isSuccess && decision != null) { - - selectedStockCode = decision.stockCode - selectedStockName = decision.stockName - isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 + 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 } @@ -62,29 +84,40 @@ fun DashboardScreen() { val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 suspend fun syncAndExecute(orderNo: String) { - // 이미 다른 코루틴에서 이 주문을 처리 중이라면 즉시 종료 (중복 방지) if (processingIds.contains(orderNo)) return processingIds.add(orderNo) try { - // DB 아이템과 체결 데이터(캐시)를 모두 가져옴 val dbItem = DatabaseFactory.findByOrderNo(orderNo) val execData = executionCache[orderNo] - // 둘 다 존재할 때만 로직 실행 if (dbItem != null && execData != null && execData.isFilled) { if (dbItem.status == TradeStatus.PENDING_BUY) { - println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} (${orderNo})") + // 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) + val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice + + // 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%) + + val minEffectiveRate = minimumNetProfit + feesAndTaxRate + + // 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)}% 적용)") - val sellPrice = dbItem.targetPrice.toLong().toString() tradeService.postOrder( stockCode = dbItem.code, qty = dbItem.quantity.toString(), - price = sellPrice, + price = finalTargetPrice.toLong().toString(), isBuy = false ).onSuccess { newSellOrderNo -> + // 익절가 업데이트 및 상태 변경 DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) - // 처리가 완료된 체결 데이터는 캐시에서 삭제 + // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능 + executionCache.remove(orderNo) refreshTrigger++ }.onFailure { @@ -98,7 +131,6 @@ fun DashboardScreen() { } } } finally { - // 처리가 끝나면(성공/실패/매칭실패 모두) 잠금 해제 processingIds.remove(orderNo) } } @@ -155,6 +187,11 @@ fun DashboardScreen() { 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, @@ -166,7 +203,7 @@ fun DashboardScreen() { syncAndExecute(orderNo) // 매칭 시도 } }, - completeTradingDecision + completeTradingDecision = completeTradingDecision, ) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -178,6 +215,13 @@ fun DashboardScreen() { VerticalDivider() Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) { AiAnalysisView( + technicalAnalyzer = TechnicalAnalyzer().apply { + this.min30 = min30 + this.daily = daySummary + this.weekly = weekSummary + this.monthly = monthSummary + this.weekly = weekSummary + }, stockCode = selectedStockCode, stockName = selectedStockName, currentPrice = wsManager.currentPrice.value, diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 015a66a..ce86c3e 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import model.feesAndTaxRate +import model.minimumNetProfit import network.KisTradeService import util.MarketUtil @@ -70,10 +72,10 @@ fun IntegratedOrderSection( } var profitRate by remember(monitoringItem) { - mutableStateOf(monitoringItem?.profitRate?.toString() ?: "2.0") + mutableStateOf(monitoringItem?.profitRate?.toString() ?: "0.8") } var stopLossRate by remember(monitoringItem) { - mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-2.0") + mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5") } // 계산용 변수 @@ -83,31 +85,51 @@ fun IntegratedOrderSection( fun excuteTrade(willEnableAutoSell: Boolean,orderQty: String) { scope.launch { - val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice + val tickSize = MarketUtil.getTickSize(basePrice) + val oneTickLowerPrice = basePrice - tickSize + + // 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용) + val finalPrice = if (orderPrice.isBlank()) { + oneTickLowerPrice.toLong().toString() + } else { + orderPrice + } + println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 onOrderResult("주문 성공: $realOrderNo", true) if (willEnableAutoSell) { + // 1. 기본 설정값 파싱 val pRate = profitRate.toDoubleOrNull() ?: 0.0 val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 - val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0)) + +// 2. 수수료 및 세금 보정치 설정 (국내 주식 기준 약 0.25% ~ 0.3%) +// 유관기관 수수료 및 매도세금을 고려하여 안전하게 0.3%로 잡거나, 필요시 더 높게 설정 가능합니다. + +// 3. 실질 목표 수익률 계산 +// 사용자가 입력한 pRate와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다. + val effectiveProfitRate = maxOf(pRate, minimumNetProfit + feesAndTaxRate) + +// 4. 보정된 수익률을 적용하여 목표가 계산 + val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) +// 5. DB 저장 (effectiveProfitRate를 저장하여 분석 시 실제 목표치를 확인 가능하게 함) DatabaseFactory.saveAutoTrade(AutoTradeItem( - orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙) + orderNo = realOrderNo, code = stockCode, name = stockName, quantity = inputQty, - profitRate = pRate, + profitRate = effectiveProfitRate, // 보정된 수익률 저장 stopLossRate = sRate, targetPrice = calculatedTarget, stopLossPrice = calculatedStop, - status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태 + status = "PENDING_BUY", isDomestic = isDomestic )) monitoringItem = DatabaseFactory.findConfigByCode(stockCode) onOrderSaved(realOrderNo) - onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true) + onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.2f", effectiveProfitRate)}%): $realOrderNo", true) } } .onFailure { onOrderResult(it.message ?: "매수 실패", false) } @@ -115,22 +137,27 @@ fun IntegratedOrderSection( } LaunchedEffect(completeTradingDecision) { val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 - val MIN_MID_SCORE = 65.0 // 최소 중기 점수 (주봉/재무) - println("completeTradingDecision = $completeTradingDecision") + val MIN_SAFE_SCORE = 65.0 // 최소 중기 점수 (주봉/재무) + val MIN_POSSIBLE_SCORE = 55.0 // 최소 중기 점수 (주봉/재무) + val MIN_SHORT_SCORE = 60.0 // 최소 중기 점수 (주봉/재무) + var append = 0.0 if (completeTradingDecision != null && completeTradingDecision.stockCode.equals(stockCode)) { - println(completeTradingDecision?.decision) + fun resultCheck(completeTradingDecision :TradingDecision) { println(""" - ${completeTradingDecision.corpName} - ${completeTradingDecision.confidence} - ${completeTradingDecision.profitPossible()} - ${completeTradingDecision.safePossible()} + corpName : ${completeTradingDecision.corpName} + confidence : ${completeTradingDecision.confidence + append} + shortPossible : ${completeTradingDecision.shortPossible() + append} + profitPossible : ${completeTradingDecision.profitPossible()+ append} + safePossible : ${completeTradingDecision.safePossible()+ append} """.trimIndent()) // 2. 조건 검사: 신뢰도 80 이상 AND 중기 점수 70 이상 - if (completeTradingDecision.confidence >= MIN_CONFIDENCE && - completeTradingDecision.profitPossible() >= MIN_MID_SCORE && - completeTradingDecision.safePossible() > MIN_MID_SCORE) { + if (completeTradingDecision.confidence + append >= MIN_CONFIDENCE && + completeTradingDecision.shortPossible() + append >= MIN_SHORT_SCORE && + completeTradingDecision.profitPossible() + append >= MIN_POSSIBLE_SCORE && + completeTradingDecision.safePossible() + append >= MIN_SAFE_SCORE + ) { println("🚀 [조건 만족] 강력 매수 시그널 포착 -> 자동 매수 진행 (1주) ${completeTradingDecision.stockCode}") // 3. 매수 실행 (자동 감시 켜기: true, 수량: 1주) @@ -143,11 +170,13 @@ fun IntegratedOrderSection( } when (completeTradingDecision?.decision) { "BUY" -> { + append = 3.0 println("[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}") resultCheck(completeTradingDecision) } "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") else -> { + append = 0.0 resultCheck(completeTradingDecision) println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}") } diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index e12f6e8..5148b38 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -36,7 +36,12 @@ fun StockDetailSection( tradeService: KisTradeService, wsManager: KisWebSocketManager, onOrderSaved: (String) -> Unit, - completeTradingDecision: TradingDecision? + completeTradingDecision: TradingDecision?, + min30 : MutableList, + daySummary : MutableList, + weekSummary : MutableList, + monthSummary : MutableList, + yearSummary : MutableList ) { var openPrice by remember { mutableStateOf("0") } @@ -44,10 +49,7 @@ fun StockDetailSection( var isLoading by remember { mutableStateOf(false) } var resultMessage by remember { mutableStateOf("") } var isSuccess by remember { mutableStateOf(true) } - var daySummary by remember { mutableStateOf>(emptyList()) } - var weekSummary by remember { mutableStateOf>(emptyList()) } - var monthSummary by remember { mutableStateOf>(emptyList()) } - var yearSummary by remember { mutableStateOf>(emptyList()) } + val todayOpen = remember(daySummary) { daySummary.lastOrNull()?.stck_oprc ?: "0" @@ -79,12 +81,13 @@ fun StockDetailSection( // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) coroutineScope { - TechnicalAnalyzer.clear() + launch {tradeService.fetchChartData(stockCode, isDomestic) .onSuccess { data -> println("✅ 차트 데이터 로드 성공: ${data.size}개") // ${} 사용하여 정확히 출력 chartData = data - TechnicalAnalyzer.min30 = chartData + min30.clear() + min30.addAll(chartData) } .onFailure { error -> println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") @@ -92,22 +95,21 @@ fun StockDetailSection( } } launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { - daySummary = it.takeLast(7) - TechnicalAnalyzer.daily = it -// println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") + daySummary.clear() + daySummary.addAll(it) } } // 최근 7일 launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { - weekSummary = it.takeLast(4) - TechnicalAnalyzer.weekly = it + 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 = it.takeLast(6) // 최근 6개월 - yearSummary = it.takeLast(36) // 최근 3년 - TechnicalAnalyzer.monthly = it -// println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") + monthSummary.clear() + monthSummary.addAll(it.takeLast(6)) + yearSummary.clear() + yearSummary.addAll(it.takeLast(36)) } } launch { diff --git a/src/main/kotlin/util/MarketUtil.kt b/src/main/kotlin/util/MarketUtil.kt index 771eb5d..f3d27a1 100644 --- a/src/main/kotlin/util/MarketUtil.kt +++ b/src/main/kotlin/util/MarketUtil.kt @@ -14,6 +14,18 @@ object MarketUtil { return now.isAfter(start) && now.isBefore(end) } + fun getTickSize(price: Double): Double { + return when { + price < 2000 -> 1.0 + price < 5000 -> 5.0 + price < 20000 -> 10.0 + price < 50000 -> 50.0 + price < 200000 -> 100.0 + price < 500000 -> 500.0 + else -> 1000.0 + } + } + fun roundToTickSize(price: Double): Double { val tick = when { price < 2000 -> 1.0