This commit is contained in:
lunaticbum 2026-02-04 14:52:09 +09:00
parent a51ffb6193
commit 1787b72499
11 changed files with 314 additions and 112 deletions

View File

@ -24,6 +24,7 @@ import network.DartCodeManager
import service.LlamaServerManager import service.LlamaServerManager
import network.NewsService import network.NewsService
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import service.SystemSleepPreventer
import ui.DashboardScreen import ui.DashboardScreen
import ui.SettingsScreen import ui.SettingsScreen
@ -31,6 +32,7 @@ import ui.SettingsScreen
enum class AppScreen { Settings, Dashboard } enum class AppScreen { Settings, Dashboard }
fun main() = application { fun main() = application {
SystemSleepPreventer.start()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// NewsService나 KisTradeService에서 사용하는 client를 전달 // NewsService나 KisTradeService에서 사용하는 client를 전달
DartCodeManager.updateCorpCodes(HttpClient(CIO) { DartCodeManager.updateCorpCodes(HttpClient(CIO) {

View File

@ -2,6 +2,9 @@ package model
import java.time.LocalDateTime import java.time.LocalDateTime
const val feesAndTaxRate = 0.3
const val minimumNetProfit = 0.8
data class AppConfig( data class AppConfig(
// [DB 저장 데이터] // [DB 저장 데이터]
// 실전 3종 // 실전 3종

View File

@ -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 호출) // 1. 10분간의 데이터 가져오기 (API 호출)
coroutineScope { coroutineScope {
try { try {
@ -146,7 +146,7 @@ object RagService {
tradingDecision.financialData = financialDataDeferred.await() tradingDecision.financialData = financialDataDeferred.await()
result(tradingDecision, false) result(tradingDecision, false)
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport() tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport()
result(tradingDecision, false) result(tradingDecision, false)
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
@ -333,6 +333,11 @@ class TradingDecision {
var newsContext : String? = null var newsContext : String? = null
var financialData : String? = null var financialData : String? = null
fun shortPossible() =
listOf<Double>(ultraShortScore,
shortTermScore).average()
fun profitPossible() = fun profitPossible() =
listOf<Double>(ultraShortScore, listOf<Double>(ultraShortScore,
shortTermScore, shortTermScore,

View File

@ -40,7 +40,7 @@ object AutoTradingManager {
try { try {
// 1. [체크] 현재 잔고 및 보유 종목 조회 // 1. [체크] 현재 잔고 및 보유 종목 조회
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() 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 val myCash = balanceResult?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
println("💰 보유 현금: ${String.format("%,d", myCash)}원 | 보유 종목 수: ${myHoldings.size}") println("💰 보유 현금: ${String.format("%,d", myCash)}원 | 보유 종목 수: ${myHoldings.size}")
@ -49,31 +49,42 @@ object AutoTradingManager {
// 1. 랭킹 데이터 가져오기 (비동기) // 1. 랭킹 데이터 가져오기 (비동기)
val volRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) } val volRankDeferred = async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }
val riseRankDeferred = async { tradeService.fetchMarketRanking(RankingType.RISE, 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 volList = volRankDeferred.await()
val riseList = riseRankDeferred.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 val volCandidates = volList
.filter { stock -> .filter { stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 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개 // (B) 상승률 상위 종목 중: 너무 급등한(20% 초과) 종목은 제외하고, 적당히 오르고 있는 종목만 필터링 -> 상위 10개
// 보통 상승률 랭킹은 상한가(30%)부터 내려오므로, 앞쪽의 급등주를 건너뛰어야 함 // 보통 상승률 랭킹은 상한가(30%)부터 내려오므로, 앞쪽의 급등주를 건너뛰어야 함
val riseCandidates = riseList val riseCandidates = riseList
.filter { stock -> .filter { stock ->
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0 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 volumeCandidates = volumeList .filter { stock ->
val candidates = (volCandidates + riseCandidates).distinctBy { it.code } 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}개 (급등주 제외) 검증 시작...") println("🔎 1차 필터링 후보 ${candidates.size}개 (급등주 제외) 검증 시작...")
@ -84,14 +95,19 @@ object AutoTradingManager {
val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0 val currentPrice = stock.stck_prpr.replace(",", "").toDoubleOrNull() ?: 0.0
// [조건 2] 최소 1주 매수 가능 여부 // [조건 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 입력용) // 3. 일봉 데이터 조회 (필터링 용도 + TechnicalAnalyzer 입력용)
val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true) val dailyResult = tradeService.fetchPeriodChartData(stock.code, "D", true)
val dailyData = dailyResult.getOrNull() val dailyData = dailyResult.getOrNull()
val todayCandle = dailyData?.lastOrNull() val todayCandle = dailyData?.lastOrNull()
if (dailyData != null && todayCandle != null) { if (dailyData != null && todayCandle != null) {
val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0 val open = todayCandle.stck_oprc.toDoubleOrNull() ?: 0.0
val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0 val current = todayCandle.stck_prpr.toDoubleOrNull() ?: 0.0
@ -113,14 +129,14 @@ object AutoTradingManager {
val monthlyData = monthDef.await() val monthlyData = monthDef.await()
// TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수) // TechnicalAnalyzer 상태 업데이트 (싱글톤이므로 순차 처리 필수)
TechnicalAnalyzer.clear() val t = TechnicalAnalyzer()
TechnicalAnalyzer.daily = dailyData t.daily = dailyData
TechnicalAnalyzer.weekly = weeklyData t.weekly = weeklyData
TechnicalAnalyzer.monthly = monthlyData t.monthly = monthlyData
TechnicalAnalyzer.min30 = min30Data t.min30 = min30Data
// 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지) // 데이터 준비 완료 후 AI 분석 요청 (suspend 함수이므로 완료될 때까지 대기 -> 데이터 섞임 방지)
RagService.processStock(stock.name, stock.code) { decision, isSuccess -> RagService.processStock(t,stock.name, stock.code) { decision, isSuccess ->
if (decision != null) { if (decision != null) {
decision.stockName = stock.name decision.stockName = stock.name
decision.currentPrice = current // 차트에서 확인한 최신 현재가 주입 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) { } catch (e: Exception) {
println("⚠️ 루프 오류: ${e.message}") 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 { 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<CandleData> = emptyList() var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList() var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList() var daily: List<CandleData> = emptyList()
@ -184,21 +218,21 @@ object TechnicalAnalyzer {
): InvestmentScores { ): InvestmentScores {
// 1. 초단기 (분봉 + 에너지 지표 위주) // 1. 초단기 (분봉 + 에너지 지표 위주)
val ultra = (TechnicalAnalyzer.calculateMFI(min30, 14) * 0.4 + val ultra = (calculateMFI(min30, 14) * 0.4 +
TechnicalAnalyzer.calculateStochastic(min30) * 0.3 + calculateStochastic(min30) * 0.3 +
(if(TechnicalAnalyzer.calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt() (if(calculateChange(min30.takeLast(10)) > 0) 30 else 0)).toInt()
// 2. 단기 (일봉 추세 + OBV 에너지) // 2. 단기 (일봉 추세 + OBV 에너지)
val short = (TechnicalAnalyzer.calculateRSI(daily) * 0.3 + val short = (calculateRSI(daily) * 0.3 +
(if(TechnicalAnalyzer.calculateOBV(daily) > 0) 40 else 10) + (if(calculateOBV(daily) > 0) 40 else 10) +
(if(TechnicalAnalyzer.calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt() (if(calculateChange(daily.takeLast(3)) > 0) 30 else 0)).toInt()
// 3. 중기 (주봉 + 재무 점수 혼합) // 3. 중기 (주봉 + 재무 점수 혼합)
val mid = (if(TechnicalAnalyzer.calculateChange(weekly) > 0) 40 else 10) + val mid = (if(calculateChange(weekly) > 0) 40 else 10) +
(financialScore * 0.6).toInt() (financialScore * 0.6).toInt()
// 4. 장기 (월봉 + 섹터/기업 펀더멘털) // 4. 장기 (월봉 + 섹터/기업 펀더멘털)
val long = (if(TechnicalAnalyzer.calculateChange(monthly) > 0) 50 else 0) + val long = (if(calculateChange(monthly) > 0) 50 else 0) +
(financialScore * 0.5).toInt() (financialScore * 0.5).toInt()
return InvestmentScores( return InvestmentScores(
@ -390,8 +424,8 @@ class ScalpingAnalyzer {
private const val BB_LOWER_POS = 0.2 private const val BB_LOWER_POS = 0.2
private const val BB_UPPER_POS = 0.8 private const val BB_UPPER_POS = 0.8
private const val ATR_WINDOW = 14 private const val ATR_WINDOW = 14
private const val DEFAULT_SL_PCT = -0.5 private const val DEFAULT_SL_PCT = -1.5
private const val DEFAULT_TP_PCT = 1.0 private const val DEFAULT_TP_PCT = 1.5
private const val HIGH_SCORE_THRESHOLD = 80 private const val HIGH_SCORE_THRESHOLD = 80
} }

View File

@ -3,6 +3,7 @@ package service
import com.microsoft.playwright.Playwright import com.microsoft.playwright.Playwright
import com.microsoft.playwright.BrowserType import com.microsoft.playwright.BrowserType
import com.microsoft.playwright.Page import com.microsoft.playwright.Page
import com.microsoft.playwright.options.LoadState
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -92,33 +93,58 @@ object DynamicNewsScraper {
} }
suspend fun fetchFullContent(url: String): String { suspend fun fetchFullContent(url: String): String {
// browser.newContext().use { ... } 대신 직접 변수를 선언하고 제어합니다.
val context = browser.newContext() val context = browser.newContext()
val page = context.newPage()
delay(Random.nextInt(1000).toLong())
return try { return try {
// 1. 페이지 이동 및 네트워크 유휴 상태까지 대기 context.use { ctx ->
blockUnnecessaryResources(page) ctx.newPage().use { page ->
page.navigate(url) delay(Random.nextInt(1000).toLong())
// println(url)
page.waitForLoadState()
// 1. 리스너 설정 시 예외 처리 강화
blockUnnecessaryResources(page)
var finded = cleanText(extractSmartContentWithLineFilter(page)) // 2. 타임아웃을 설정하여 무한 대기 방지
println("finded : $finded") val options = Page.NavigateOptions().setTimeout(30000.0)
finded page.navigate(url, options)
// 3. 페이지가 완전히 닫히기 전에 모든 대기 중인 이벤트를 해제하기 위해 LOAD 상태 대기
page.waitForLoadState(LoadState.LOAD)
val content = cleanText(extractSmartContentWithLineFilter(page))
// 4. 명시적으로 route를 해제하여 close 시 발생할 수 있는 리스너 충돌 방지
page.unroute("**/*")
content
}
}
} catch (e: Exception) { } catch (e: Exception) {
println("❌ [Playwright] 스크래핑 실패: ${e.message}") println("❌ [Playwright] 스크래핑 실패 ($url): ${e.message}")
"" ""
} finally { } finally {
page.close() // use 블록이 자원을 닫으려 할 때 발생하는 오류는 내부적으로 처리되거나 무시되도록 유도
context.close()
} }
} }
private fun blockUnnecessaryResources(page: Page) { private fun blockUnnecessaryResources(page: Page) {
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단 // 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route -> page.route("**/*") { route ->
route.abort() 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 -> urls.map { item ->
async { async {
if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) { if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) {
semaphore.withPermit { try {
RagService.ingestWithChunking( semaphore.withPermit {
text = DynamicNewsScraper.fetchFullContent(item.originallink), try {
newsLink = item.originallink, RagService.ingestWithChunking(
pubDate = item.pubDate, text = DynamicNewsScraper.fetchFullContent(item.originallink),
stockCode = corpInfo.stockCode, newsLink = item.originallink,
corpName = corpInfo.cName, pubDate = item.pubDate,
corpCode = corpInfo.cCode, stockCode = corpInfo.stockCode,
stcokName = corpInfo.stockName corpName = corpInfo.cName,
) corpCode = corpInfo.cCode,
stcokName = corpInfo.stockName
)
}catch (e: Exception) {
println("${e.message}")
}
}
}catch (e: Exception) {
println("${e.message}")
} }
println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링") println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링")
} else { } else {

View File

@ -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 종료됨: 시스템 절전 설정이 정상화됩니다.")
}
}

View File

@ -26,10 +26,11 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.KisSession import model.KisSession
import service.AutoTradingManager import service.AutoTradingManager
import service.TechnicalAnalyzer
import service.TradingDecisionCallback import service.TradingDecisionCallback
@Composable @Composable
fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) { fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>, tradingDecisionCallback: TradingDecisionCallback) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") } var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var code by remember(stockCode) { var code by remember(stockCode) {
aiOpinion = "" aiOpinion = ""
@ -67,7 +68,7 @@ fun AiAnalysisView(stockCode:String,stockName: String, currentPrice: String, tra
scope.launch { scope.launch {
isAnalyzing = true isAnalyzing = true
try { try {
AutoTradingManager.addStock(stockName,stockCode) { decision,success -> AutoTradingManager.addStock(technicalAnalyzer,stockName,stockCode) { decision,success ->
aiOpinion = decision.toString() aiOpinion = decision.toString()
isAnalyzing = !success isAnalyzing = !success
tradingDecisionCallback.invoke(decision,success) tradingDecisionCallback.invoke(decision,success)

View File

@ -12,12 +12,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.CandleData
import model.ExecutionData import model.ExecutionData
import model.KisSession import model.KisSession
import model.StockBasicInfo import model.StockBasicInfo
import model.feesAndTaxRate
import model.minimumNetProfit
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import service.AutoTradingManager import service.AutoTradingManager
import service.TechnicalAnalyzer
import util.MarketUtil
import kotlin.collections.mutableListOf
@Composable @Composable
fun DashboardScreen() { fun DashboardScreen() {
@ -33,17 +39,33 @@ fun DashboardScreen() {
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시 var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(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()) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
// 1. 화면 진입 시: 자동 발굴 루프 시작 // 1. 화면 진입 시: 자동 발굴 루프 시작
// AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여 // AI 분석 결과(decision)가 나오면 completeTradingDecision 상태를 업데이트하여
// IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다. // IntegratedOrderSection에서 자동으로 매수 로직이 실행되도록 연결합니다.
AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess -> AutoTradingManager.startAutoDiscoveryLoop(tradeService) { decision, isSuccess ->
if (isSuccess && decision != null) { if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
decision?.stockCode?.let { stockCode ->
selectedStockCode = decision.stockCode decision?.stockName?.let { stockName ->
selectedStockName = decision.stockName selectedStockCode = stockCode
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정 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 트리거 // 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
completeTradingDecision = decision completeTradingDecision = decision
} }
@ -62,29 +84,40 @@ fun DashboardScreen() {
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금 val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
suspend fun syncAndExecute(orderNo: String) { suspend fun syncAndExecute(orderNo: String) {
// 이미 다른 코루틴에서 이 주문을 처리 중이라면 즉시 종료 (중복 방지)
if (processingIds.contains(orderNo)) return if (processingIds.contains(orderNo)) return
processingIds.add(orderNo) processingIds.add(orderNo)
try { try {
// DB 아이템과 체결 데이터(캐시)를 모두 가져옴
val dbItem = DatabaseFactory.findByOrderNo(orderNo) val dbItem = DatabaseFactory.findByOrderNo(orderNo)
val execData = executionCache[orderNo] val execData = executionCache[orderNo]
// 둘 다 존재할 때만 로직 실행
if (dbItem != null && execData != null && execData.isFilled) { if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) { 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( tradeService.postOrder(
stockCode = dbItem.code, stockCode = dbItem.code,
qty = dbItem.quantity.toString(), qty = dbItem.quantity.toString(),
price = sellPrice, price = finalTargetPrice.toLong().toString(),
isBuy = false isBuy = false
).onSuccess { newSellOrderNo -> ).onSuccess { newSellOrderNo ->
// 익절가 업데이트 및 상태 변경
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
// 처리가 완료된 체결 데이터는 캐시에서 삭제 // (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
executionCache.remove(orderNo) executionCache.remove(orderNo)
refreshTrigger++ refreshTrigger++
}.onFailure { }.onFailure {
@ -98,7 +131,6 @@ fun DashboardScreen() {
} }
} }
} finally { } finally {
// 처리가 끝나면(성공/실패/매칭실패 모두) 잠금 해제
processingIds.remove(orderNo) processingIds.remove(orderNo)
} }
} }
@ -155,6 +187,11 @@ fun DashboardScreen() {
Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) { Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
if (selectedStockCode.isNotEmpty()) { if (selectedStockCode.isNotEmpty()) {
StockDetailSection( StockDetailSection(
min30 = min30,
daySummary = daySummary,
monthSummary = monthSummary,
weekSummary = weekSummary,
yearSummary = yearSummary,
stockCode = selectedStockCode, stockCode = selectedStockCode,
stockName = selectedStockName, stockName = selectedStockName,
holdingQuantity = selectedStockQuantity, holdingQuantity = selectedStockQuantity,
@ -166,7 +203,7 @@ fun DashboardScreen() {
syncAndExecute(orderNo) // 매칭 시도 syncAndExecute(orderNo) // 매칭 시도
} }
}, },
completeTradingDecision completeTradingDecision = completeTradingDecision,
) )
} else { } else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -178,6 +215,13 @@ fun DashboardScreen() {
VerticalDivider() VerticalDivider()
Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) { Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) {
AiAnalysisView( AiAnalysisView(
technicalAnalyzer = TechnicalAnalyzer().apply {
this.min30 = min30
this.daily = daySummary
this.weekly = weekSummary
this.monthly = monthSummary
this.weekly = weekSummary
},
stockCode = selectedStockCode, stockCode = selectedStockCode,
stockName = selectedStockName, stockName = selectedStockName,
currentPrice = wsManager.currentPrice.value, currentPrice = wsManager.currentPrice.value,

View File

@ -20,6 +20,8 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.feesAndTaxRate
import model.minimumNetProfit
import network.KisTradeService import network.KisTradeService
import util.MarketUtil import util.MarketUtil
@ -70,10 +72,10 @@ fun IntegratedOrderSection(
} }
var profitRate by remember(monitoringItem) { var profitRate by remember(monitoringItem) {
mutableStateOf(monitoringItem?.profitRate?.toString() ?: "2.0") mutableStateOf(monitoringItem?.profitRate?.toString() ?: "0.8")
} }
var stopLossRate by remember(monitoringItem) { 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) { fun excuteTrade(willEnableAutoSell: Boolean,orderQty: String) {
scope.launch { 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) tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true) onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) { if (willEnableAutoSell) {
// 1. 기본 설정값 파싱
val pRate = profitRate.toDoubleOrNull() ?: 0.0 val pRate = profitRate.toDoubleOrNull() ?: 0.0
val sRate = stopLossRate.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)) val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
// 5. DB 저장 (effectiveProfitRate를 저장하여 분석 시 실제 목표치를 확인 가능하게 함)
DatabaseFactory.saveAutoTrade(AutoTradeItem( DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙) orderNo = realOrderNo,
code = stockCode, code = stockCode,
name = stockName, name = stockName,
quantity = inputQty, quantity = inputQty,
profitRate = pRate, profitRate = effectiveProfitRate, // 보정된 수익률 저장
stopLossRate = sRate, stopLossRate = sRate,
targetPrice = calculatedTarget, targetPrice = calculatedTarget,
stopLossPrice = calculatedStop, stopLossPrice = calculatedStop,
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태 status = "PENDING_BUY",
isDomestic = isDomestic isDomestic = isDomestic
)) ))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode) monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo) onOrderSaved(realOrderNo)
onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true) onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.2f", effectiveProfitRate)}%): $realOrderNo", true)
} }
} }
.onFailure { onOrderResult(it.message ?: "매수 실패", false) } .onFailure { onOrderResult(it.message ?: "매수 실패", false) }
@ -115,22 +137,27 @@ fun IntegratedOrderSection(
} }
LaunchedEffect(completeTradingDecision) { LaunchedEffect(completeTradingDecision) {
val MIN_CONFIDENCE = 70.0 // 최소 신뢰도 val MIN_CONFIDENCE = 70.0 // 최소 신뢰도
val MIN_MID_SCORE = 65.0 // 최소 중기 점수 (주봉/재무) val MIN_SAFE_SCORE = 65.0 // 최소 중기 점수 (주봉/재무)
println("completeTradingDecision = $completeTradingDecision") val MIN_POSSIBLE_SCORE = 55.0 // 최소 중기 점수 (주봉/재무)
val MIN_SHORT_SCORE = 60.0 // 최소 중기 점수 (주봉/재무)
var append = 0.0
if (completeTradingDecision != null && if (completeTradingDecision != null &&
completeTradingDecision.stockCode.equals(stockCode)) { completeTradingDecision.stockCode.equals(stockCode)) {
println(completeTradingDecision?.decision)
fun resultCheck(completeTradingDecision :TradingDecision) { fun resultCheck(completeTradingDecision :TradingDecision) {
println(""" println("""
${completeTradingDecision.corpName} corpName : ${completeTradingDecision.corpName}
${completeTradingDecision.confidence} confidence : ${completeTradingDecision.confidence + append}
${completeTradingDecision.profitPossible()} shortPossible : ${completeTradingDecision.shortPossible() + append}
${completeTradingDecision.safePossible()} profitPossible : ${completeTradingDecision.profitPossible()+ append}
safePossible : ${completeTradingDecision.safePossible()+ append}
""".trimIndent()) """.trimIndent())
// 2. 조건 검사: 신뢰도 80 이상 AND 중기 점수 70 이상 // 2. 조건 검사: 신뢰도 80 이상 AND 중기 점수 70 이상
if (completeTradingDecision.confidence >= MIN_CONFIDENCE && if (completeTradingDecision.confidence + append >= MIN_CONFIDENCE &&
completeTradingDecision.profitPossible() >= MIN_MID_SCORE && completeTradingDecision.shortPossible() + append >= MIN_SHORT_SCORE &&
completeTradingDecision.safePossible() > MIN_MID_SCORE) { completeTradingDecision.profitPossible() + append >= MIN_POSSIBLE_SCORE &&
completeTradingDecision.safePossible() + append >= MIN_SAFE_SCORE
) {
println("🚀 [조건 만족] 강력 매수 시그널 포착 -> 자동 매수 진행 (1주) ${completeTradingDecision.stockCode}") println("🚀 [조건 만족] 강력 매수 시그널 포착 -> 자동 매수 진행 (1주) ${completeTradingDecision.stockCode}")
// 3. 매수 실행 (자동 감시 켜기: true, 수량: 1주) // 3. 매수 실행 (자동 감시 켜기: true, 수량: 1주)
@ -143,11 +170,13 @@ fun IntegratedOrderSection(
} }
when (completeTradingDecision?.decision) { when (completeTradingDecision?.decision) {
"BUY" -> { "BUY" -> {
append = 3.0
println("[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}") println("[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}")
resultCheck(completeTradingDecision) resultCheck(completeTradingDecision)
} }
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}") "SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
else -> { else -> {
append = 0.0
resultCheck(completeTradingDecision) resultCheck(completeTradingDecision)
println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}") println("[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}")
} }

View File

@ -36,7 +36,12 @@ fun StockDetailSection(
tradeService: KisTradeService, tradeService: KisTradeService,
wsManager: KisWebSocketManager, wsManager: KisWebSocketManager,
onOrderSaved: (String) -> Unit, onOrderSaved: (String) -> Unit,
completeTradingDecision: TradingDecision? completeTradingDecision: TradingDecision?,
min30 : MutableList<CandleData>,
daySummary : MutableList<CandleData>,
weekSummary : MutableList<CandleData>,
monthSummary : MutableList<CandleData>,
yearSummary : MutableList<CandleData>
) { ) {
var openPrice by remember { mutableStateOf("0") } var openPrice by remember { mutableStateOf("0") }
@ -44,10 +49,7 @@ fun StockDetailSection(
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var resultMessage by remember { mutableStateOf("") } var resultMessage by remember { mutableStateOf("") }
var isSuccess by remember { mutableStateOf(true) } var isSuccess by remember { mutableStateOf(true) }
var daySummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
var weekSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
var monthSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
var yearSummary by remember { mutableStateOf<List<CandleData>>(emptyList()) }
val todayOpen = remember(daySummary) { val todayOpen = remember(daySummary) {
daySummary.lastOrNull()?.stck_oprc ?: "0" daySummary.lastOrNull()?.stck_oprc ?: "0"
@ -79,12 +81,13 @@ fun StockDetailSection(
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화) // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
coroutineScope { coroutineScope {
TechnicalAnalyzer.clear()
launch {tradeService.fetchChartData(stockCode, isDomestic) launch {tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data -> .onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력 println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력
chartData = data chartData = data
TechnicalAnalyzer.min30 = chartData min30.clear()
min30.addAll(chartData)
} }
.onFailure { error -> .onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}") println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
@ -92,22 +95,21 @@ fun StockDetailSection(
} }
} }
launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess { launch { tradeService.fetchPeriodChartData(stockCode, "D").onSuccess {
daySummary = it.takeLast(7) daySummary.clear()
TechnicalAnalyzer.daily = it daySummary.addAll(it)
// println("daySummary ${daySummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
} }
} // 최근 7일 } // 최근 7일
launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess { launch { tradeService.fetchPeriodChartData(stockCode, "W").onSuccess {
weekSummary = it.takeLast(4) weekSummary.clear()
TechnicalAnalyzer.weekly = it weekSummary.addAll(it.takeLast(4))
// println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") // println("weekSummary ${weekSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}")
} }
} // 최근 4주 } // 최근 4주
launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess { launch { tradeService.fetchPeriodChartData(stockCode, "M").onSuccess {
monthSummary = it.takeLast(6) // 최근 6개월 monthSummary.clear()
yearSummary = it.takeLast(36) // 최근 3년 monthSummary.addAll(it.takeLast(6))
TechnicalAnalyzer.monthly = it yearSummary.clear()
// println("monthSummary ${monthSummary.size} yearSummary ${yearSummary.size} total: ${it.size} ${it.firstOrNull()?.toString()}") yearSummary.addAll(it.takeLast(36))
} }
} }
launch { launch {

View File

@ -14,6 +14,18 @@ object MarketUtil {
return now.isAfter(start) && now.isBefore(end) 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 { fun roundToTickSize(price: Double): Double {
val tick = when { val tick = when {
price < 2000 -> 1.0 price < 2000 -> 1.0