This commit is contained in:
2026-02-04 14:52:09 +09:00
parent a51ffb6193
commit 1787b72499
11 changed files with 314 additions and 112 deletions
+3 -2
View File
@@ -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<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 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)
+58 -14
View File
@@ -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<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()) }
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<String>() } // 주문번호 기준 잠금
// [중앙 관리 함수] 체결 정보와 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,
+47 -18
View File
@@ -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}")
}
+18 -16
View File
@@ -36,7 +36,12 @@ fun StockDetailSection(
tradeService: KisTradeService,
wsManager: KisWebSocketManager,
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") }
@@ -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<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) {
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 {