import kotlinx.coroutines.* import java.awt.Desktop import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.time.Duration object LocalReportGenerator { private val reportScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // --- [Raw 데이터 모델 유지] --- data class RawSummaryData( val type: String, val startAsset: Long, val endAsset: Long, val dailyAssetChange: Long, val todayFees: Long, val totalProfitRate: Double ) data class RawHoldingData( val stockName: String, val quantity: Int, val avgPrice: Double, val currentPrice: Double, val evalAmount: Long, // 💡 금일 OHLC 정보 추가 val openPrice: Double = 0.0, val highPrice: Double = 0.0, val lowPrice: Double = 0.0, val closePrice: Double = 0.0 ) data class RawExecutionData( val price: Double, val quantity: Int, val execTime: String ) data class RawTradeData( val stockCode: String, val stockName: String, val isBuy: Boolean, val status: String, val orderTime: String, val executions: List, val currentPrice: Double, val resolvedBuyPrice: Double, val investmentGrade: String, val reason: String, val aiScore: Double, // 💡 금일 OHLC 정보 추가 val openPrice: Double = 0.0, val highPrice: Double = 0.0, val lowPrice: Double = 0.0, val closePrice: Double = 0.0 ) // 💡 내부 대시보드 통계용 데이터 클래스 private data class DashboardStats( val buyOrderCount: Int, // 금일 총 매수 주문 횟수 val sellOrderCount: Int, // 금일 총 매도 주문 횟수 val completedCycles: Int, // 진입 후 청산 완료 건수 val pendingCycles: Int, // 진입 후 미완료(보유) 건수 val totalRealizedProfit: Long, // 총 실현 수익금 val avgRealizedProfit: Long, // 거래당 평균 수익금 val avgRealizedRate: Double, // 거래당 평균 수익률 val winRate: Double, // 승률 val bestTradeName: String, val bestTradeProfit: Long ) var lastSavedTime = -1L val reportOpenTime = 2L * 60L * 60L * 1000L fun generateAndOpenAsync( summary: RawSummaryData, rawHoldings: List, rawTrades: List ) { reportScope.launch { try { // 1. [핵심] 대시보드 통계 지표 추출 (Generator가 직접 계산) val stats = calculateDashboardStats(rawHoldings, rawTrades) // 2. 탭 2 & 3 HTML 가공 val holdingsHtml = processHoldings(rawHoldings) val tradesHtml = processTrades(rawTrades) // 3. 전체 HTML 조립 val htmlContent = buildHtml(summary, stats, holdingsHtml, tradesHtml) var currentTime = System.currentTimeMillis() if (summary.type.equals("END", true) || currentTime >= (lastSavedTime + reportOpenTime)) { saveAndOpen(summary.type, htmlContent) lastSavedTime = currentTime } } catch (e: Exception) { println("❌ [Report] 리포트 비동기 생성 중 오류 발생: ${e.message}") e.printStackTrace() } } } // --- [새로운 통계 계산 로직] --- private fun calculateDashboardStats(holdings: List, trades: List): DashboardStats { val tradesByStock = trades.groupBy { it.stockCode } var buyCount = 0 var sellCount = 0 var completed = 0 var pending = 0 var totalProfit = 0L var totalRateSum = 0.0 var closedTradeCount = 0 var bestName = "-" var bestProfit = Long.MIN_VALUE tradesByStock.forEach { (_, stockTrades) -> val buys = stockTrades.filter { it.isBuy } val sells = stockTrades.filter { !it.isBuy } buyCount += buys.size sellCount += sells.size // 1. 청산 완료 여부 판별 if (buys.isNotEmpty() && sells.isNotEmpty()) { completed++ val avgBuy = calculateAvgPrice(buys) val avgSell = calculateAvgPrice(sells) val totalQty = sells.flatMap { it.executions }.sumOf { it.quantity } val profit = ((avgSell - avgBuy) * totalQty).toLong() val rate = if (avgBuy > 0) ((avgSell - avgBuy) / avgBuy) * 100 else 0.0 totalProfit += profit totalRateSum += rate closedTradeCount++ if (profit > bestProfit) { bestProfit = profit; bestName = stockTrades.first().stockName } } else if (buys.isNotEmpty() && sells.isEmpty()) { pending++ // 진입은 했으나 아직 안 판 종목 } else if (sells.isNotEmpty() && buys.isEmpty()) { // 스윙 종목 매도 처리 val avgSell = calculateAvgPrice(sells) val totalQty = sells.flatMap { it.executions }.sumOf { it.quantity } val buyPrice = sells.first().resolvedBuyPrice if (buyPrice > 0) { val profit = ((avgSell - buyPrice) * totalQty).toLong() val rate = ((avgSell - buyPrice) / buyPrice) * 100 totalProfit += profit totalRateSum += rate closedTradeCount++ if (profit > bestProfit) { bestProfit = profit; bestName = stockTrades.first().stockName } } } } return DashboardStats( buyOrderCount = buyCount, sellOrderCount = sellCount, completedCycles = completed, pendingCycles = pending, totalRealizedProfit = totalProfit, avgRealizedProfit = if (closedTradeCount > 0) totalProfit / closedTradeCount else 0L, avgRealizedRate = if (closedTradeCount > 0) totalRateSum / closedTradeCount else 0.0, winRate = if (closedTradeCount > 0) (tradesByStock.filter { /* 승리판별로직 */ true }.size.toDouble()) /* 실제 승률 계산 필요 시 추가 */ else 0.0, bestTradeName = bestName, bestTradeProfit = if (bestProfit == Long.MIN_VALUE) 0L else bestProfit ) } // --- [탭 2, 3 처리 로직] (이전과 거의 동일) --- private fun processHoldings(rawHoldings: List): String { if (rawHoldings.isEmpty()) return "현재 보유 중인 종목이 없습니다." return rawHoldings.joinToString("\n") { h -> val valuationProfit = ((h.currentPrice - h.avgPrice) * h.quantity).toLong() val profitRate = if (h.avgPrice > 0) ((h.currentPrice - h.avgPrice) / h.avgPrice) * 100 else 0.0 val profitClass = if (valuationProfit > 0) "plus" else if (valuationProfit < 0) "minus" else "" // 💡 4개 값이 모두 0보다 클 때만 HTML 문자열 생성 val ohlcHtml = if (h.openPrice > 0 && h.highPrice > 0 && h.lowPrice > 0 && h.currentPrice > 0) { """ 시: ${String.format("%,d", h.openPrice.toLong())}
고: ${String.format("%,d", h.highPrice.toLong())}
저: ${String.format("%,d", h.lowPrice.toLong())}
종: ${String.format("%,d", h.currentPrice.toLong())}
""".trimIndent() } else { "-" // 시세 데이터가 없을 경우 표시할 텍스트 (또는 "") } """ ${h.stockName} ${h.quantity}주 ${String.format("%,d", h.avgPrice.toLong())}원 ${String.format("%,d", h.currentPrice.toLong())}원 ${String.format("%,d", h.evalAmount)}원 ${String.format("%.2f%%", profitRate)} ${String.format("%,d", valuationProfit)}원 $ohlcHtml """ } } private fun processTrades(rawTrades: List): String { if (rawTrades.isEmpty()) return "당일 거래 내역이 없습니다." val htmlBuilder = StringBuilder() val tradesByStock = rawTrades.groupBy { it.stockCode } for ((_, trades) in tradesByStock) { val buyTrades = trades.filter { it.isBuy } val sellTrades = trades.filter { !it.isBuy } val stockName = trades.first().stockName val reason = trades.first().reason val aiScore = trades.first().aiScore val investmentGrade = trades.first().investmentGrade val h = trades.first() val ohlcHtml = if (h.openPrice > 0 && h.highPrice > 0 && h.lowPrice > 0 && h.currentPrice > 0) { """ 시: ${String.format("%,d", h.openPrice.toLong())}
고: ${String.format("%,d", h.highPrice.toLong())}
저: ${String.format("%,d", h.lowPrice.toLong())}
종: ${String.format("%,d", h.currentPrice.toLong())}
""".trimIndent() } else { "-" // 시세 데이터가 없을 경우 표시할 텍스트 (또는 "") } if (buyTrades.isNotEmpty() && sellTrades.isNotEmpty()) { val totalSellQty = sellTrades.flatMap { it.executions }.sumOf { it.quantity } val avgBuyPrice = calculateAvgPrice(buyTrades) val avgSellPrice = calculateAvgPrice(sellTrades) val profitAmount = ((avgSellPrice - avgBuyPrice) * totalSellQty).toLong() val profitRate = if (avgBuyPrice > 0) ((avgSellPrice - avgBuyPrice) / avgBuyPrice) * 100 else 0.0 val profitClass = if (profitAmount > 0) "plus" else if (profitAmount < 0) "minus" else "" val buyTime = buyTrades.first().orderTime.substringAfter("T").substringBefore(".") val sellTime = sellTrades.last().orderTime.substringAfter("T").substringBefore(".") htmlBuilder.append(""" 당일 청산 $stockName
$investmentGrade 진입: $buyTime
청산: $sellTime - ${totalSellQty}주 매수: ${String.format("%,d", avgBuyPrice.toLong())}원
매도: ${String.format("%,d", avgSellPrice.toLong())}원 ${String.format("%.2f%%", profitRate)} 실현: ${String.format("%,d", profitAmount)}원 $ohlcHtml 💡 AI (${String.format("%.1f", aiScore)}점): $reason """) } else if (sellTrades.isNotEmpty() && buyTrades.isEmpty()) { sellTrades.forEach { sell -> val sellQty = sell.executions.sumOf { it.quantity } val avgSellPrice = calculateAvgPrice(listOf(sell)) val buyPrice = sell.resolvedBuyPrice val profitAmount = if (buyPrice > 0) ((avgSellPrice - buyPrice) * sellQty).toLong() else 0L val profitRate = if (buyPrice > 0) ((avgSellPrice - buyPrice) / buyPrice) * 100 else 0.0 val profitClass = if (profitAmount > 0) "plus" else if (profitAmount < 0) "minus" else "" htmlBuilder.append(""" 스윙 청산 $stockName ${sell.orderTime.substringAfter("T").substringBefore(".")} - ${sellQty}주 매도단가: ${String.format("%,d", avgSellPrice.toLong())}원 ${String.format("%.2f%%", profitRate)} 실현: ${String.format("%,d", profitAmount)}원 $ohlcHtml """) } } else if (buyTrades.isNotEmpty() && sellTrades.isEmpty()) { buyTrades.forEach { buy -> val buyQty = buy.executions.sumOf { it.quantity } val avgBuyPrice = calculateAvgPrice(listOf(buy)) val currentPrice = buy.currentPrice val valuationAmount = if (avgBuyPrice > 0) ((currentPrice - avgBuyPrice) * buyQty).toLong() else 0L val profitClass = if (valuationAmount > 0) "plus" else if (valuationAmount < 0) "minus" else "" htmlBuilder.append(""" 신규 진입 $stockName
$investmentGrade ${buy.orderTime.substringAfter("T").substringBefore(".")} - ${buyQty}주 매수단가: ${String.format("%,d", avgBuyPrice.toLong())}원 - 평가: ${String.format("%,d", valuationAmount)}원 $ohlcHtml 💡 AI (${String.format("%.1f", aiScore)}점): $reason """) } } } return htmlBuilder.toString() } private fun calculateAvgPrice(trades: List): Double { val allExecutions = trades.flatMap { it.executions } val totalQty = allExecutions.sumOf { it.quantity } return if (totalQty > 0) allExecutions.sumOf { it.price * it.quantity } / totalQty else trades.firstOrNull()?.currentPrice ?: 0.0 } // --- [HTML 빌더] --- private fun buildHtml(summary: RawSummaryData, stats: DashboardStats, holdingsHtml: String, tradesHtml: String): String { val now = LocalDateTime.now() val dateStr = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")) val timeStr = now.format(DateTimeFormatter.ofPattern("HHmmss")) return """ ATRADE 데일리 리포트 (${summary.type})

📊 ATRADE 일일 운용 리포트 (${summary.type} : $dateStr $timeStr)

최종 운용 자산
${String.format("%,d", summary.endAsset)} 원
오늘 수익: ${if (summary.dailyAssetChange > 0) "+" else ""}${String.format("%,d", summary.dailyAssetChange)} 원

🔄 금일 주문 활동

매수 ${stats.buyOrderCount}회 / 매도 ${stats.sellOrderCount}회

🎯 청산 상태 (진입 기준)

완료 ${stats.completedCycles}건 / 미완료 ${stats.pendingCycles}건

💸 거래당 평균 수익

${String.format("%,d", stats.avgRealizedProfit)} 원 (${String.format("%.2f", stats.avgRealizedRate)}%)

💰 총 실현 손익 (확정)

${if (stats.totalRealizedProfit > 0) "+" else ""}${String.format("%,d", stats.totalRealizedProfit)} 원

🏆 오늘의 BEST

${stats.bestTradeName} (+${String.format("%,d", stats.bestTradeProfit)})

🏦 발생 제비용

${String.format("%,d", summary.todayFees)} 원

$holdingsHtml
종목명보유수량매입단가현재가평가금액수익률평가손익
OHLC
$tradesHtml
구분종목명체결시각소요시간/상태체결량체결단가수익률수익금(실현/평가)OHLC
""".trimIndent() } private fun saveAndOpen(type: String, htmlContent: String) { val now = LocalDateTime.now() val year = now.year.toString() val month = String.format("%02d", now.monthValue) val dateStr = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")) val timeStr = now.format(DateTimeFormatter.ofPattern("HHmmss")) val directory = File("reports/$year/$month") if (!directory.exists()) directory.mkdirs() val fileName = "ATRADE_REPORT_${dateStr}_${timeStr}_${type}.html" val reportFile = File(directory, fileName) reportFile.writeText(htmlContent, Charsets.UTF_8) if (Desktop.isDesktopSupported()) { Desktop.getDesktop().browse(reportFile.toURI()) } } }