atrade/src/main/kotlin/report/LocalReportGenerator.kt

503 lines
25 KiB
Kotlin

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<RawExecutionData>,
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<RawHoldingData>,
rawTrades: List<RawTradeData>
) {
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()
}
}
}
fun generateAndOpenAsyncDirectly(
summary: RawSummaryData,
rawHoldings: List<RawHoldingData>,
rawTrades: List<RawTradeData>
) {
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)
if (summary.type.equals("END", true) || summary.type.equals("MIDDLE", true)) {
saveAndOpen(summary.type, htmlContent)
}
} catch (e: Exception) {
println("❌ [Report] 리포트 비동기 생성 중 오류 발생: ${e.message}")
e.printStackTrace()
}
}
}
// --- [새로운 통계 계산 로직] ---
private fun calculateDashboardStats(holdings: List<RawHoldingData>, trades: List<RawTradeData>): 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<RawHoldingData>): String {
if (rawHoldings.isEmpty()) return "<tr><td colspan='8'>현재 보유 중인 종목이 없습니다.</td></tr>"
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) {
"""
<span style='font-size:0.85em; color:#7f8c8d;'>
시: ${String.format("%,d", h.openPrice.toLong())}<br>
고: ${String.format("%,d", h.highPrice.toLong())}<br>
저: ${String.format("%,d", h.lowPrice.toLong())}<br>
종: ${String.format("%,d", h.currentPrice.toLong())}
</span>
""".trimIndent()
} else {
"-" // 시세 데이터가 없을 경우 표시할 텍스트 (또는 "")
}
"""
<tr>
<td><strong>${h.stockName}</strong></td>
<td>${h.quantity}주</td>
<td>${String.format("%,d", h.avgPrice.toLong())}원</td>
<td>${String.format("%,d", h.currentPrice.toLong())}원</td>
<td>${String.format("%,d", h.evalAmount)}원</td>
<td class="$profitClass">${String.format("%.2f%%", profitRate)}</td>
<td class="$profitClass">${String.format("%,d", valuationProfit)}원</td>
<td>$ohlcHtml</td>
</tr>
"""
}
}
private fun processTrades(rawTrades: List<RawTradeData>): String {
if (rawTrades.isEmpty()) return "<tr><td colspan='8'>당일 거래 내역이 없습니다.</td></tr>"
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) {
"""
<span style='font-size:0.85em; color:#7f8c8d;'>
시: ${String.format("%,d", h.openPrice.toLong())}<br>
고: ${String.format("%,d", h.highPrice.toLong())}<br>
저: ${String.format("%,d", h.lowPrice.toLong())}<br>
종: ${String.format("%,d", h.currentPrice.toLong())}
</span>
""".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("""
<tr style="background-color: #f0f8ff;">
<td><span style="color:#8e44ad; font-weight:bold;">당일 청산</span></td>
<td>$stockName <br><span style="font-size:0.8em; color:#7f8c8d;">$investmentGrade</span></td>
<td>진입: $buyTime<br>청산: $sellTime</td>
<td>-</td>
<td>${totalSellQty}주</td>
<td>매수: ${String.format("%,d", avgBuyPrice.toLong())}원<br>매도: ${String.format("%,d", avgSellPrice.toLong())}원</td>
<td class="$profitClass">${String.format("%.2f%%", profitRate)}</td>
<td class="$profitClass">실현: ${String.format("%,d", profitAmount)}원</td>
<td >$ohlcHtml</td>
</tr>
<tr class="reason"><td colspan="8">💡 <strong>AI (${String.format("%.1f", aiScore)}점):</strong> $reason</td></tr>
""")
} 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("""
<tr>
<td><span class='sell'>스윙 청산</span></td>
<td>$stockName</td>
<td>${sell.orderTime.substringAfter("T").substringBefore(".")}</td>
<td>-</td>
<td>${sellQty}주</td>
<td>매도단가: ${String.format("%,d", avgSellPrice.toLong())}원</td>
<td class="$profitClass">${String.format("%.2f%%", profitRate)}</td>
<td class="$profitClass">실현: ${String.format("%,d", profitAmount)}원</td>
<td >$ohlcHtml</td>
</tr>
""")
}
} 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("""
<tr>
<td><span class='buy'>신규 진입</span></td>
<td>$stockName <br><span style="font-size:0.8em; color:#7f8c8d;">$investmentGrade</span></td>
<td>${buy.orderTime.substringAfter("T").substringBefore(".")}</td>
<td>-</td>
<td>${buyQty}주</td>
<td>매수단가: ${String.format("%,d", avgBuyPrice.toLong())}원</td>
<td class="$profitClass">-</td>
<td class="$profitClass">평가: ${String.format("%,d", valuationAmount)}원</td>
<td >$ohlcHtml</td>
</tr>
<tr class="reason"><td colspan="8">💡 <strong>AI (${String.format("%.1f", aiScore)}점):</strong> $reason</td></tr>
""")
}
}
}
return htmlBuilder.toString()
}
private fun calculateAvgPrice(trades: List<RawTradeData>): 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 """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ATRADE 데일리 리포트 (${summary.type})</title>
<style>
body { font-family: 'Pretendard', 'Malgun Gothic', sans-serif; background: #f0f2f5; color: #1a1a1a; margin: 20px; }
h1 { color: #1e293b; border-bottom: 3px solid #3b82f6; padding-bottom: 10px; font-size: 24px; }
/* 탭 UI */
.tab { overflow: hidden; background-color: #ffffff; border-radius: 12px 12px 0 0; display: flex; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.tab button { background-color: inherit; border: none; outline: none; cursor: pointer; padding: 16px 24px; transition: 0.2s; font-size: 15px; font-weight: 600; color: #64748b; flex: 1; border-bottom: 3px solid transparent; }
.tab button:hover { background-color: #f8fafc; color: #3b82f6; }
.tab button.active { color: #3b82f6; border-bottom: 3px solid #3b82f6; background-color: #eff6ff; }
.tabcontent { display: none; padding: 25px; background: #fff; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
/* 요약 대시보드 카드 */
.dashboard-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 20px; background: #1e293b; color: white; border-radius: 12px; }
.dashboard-header .main-asset { font-size: 32px; font-weight: 800; }
.dashboard-header .asset-change { font-size: 18px; font-weight: 600; padding: 4px 12px; border-radius: 20px; }
.bg-plus { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
.bg-minus { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.grid-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-bottom: 16px; }
.stat-card { background: #f8fafc; padding: 20px; border-radius: 12px; border: 1px solid #e2e8f0; }
.stat-card h3 { margin: 0 0 8px 0; font-size: 13px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card p { margin: 0; font-size: 22px; font-weight: 700; color: #0f172a; }
.highlight-plus { color: #ef4444; } /* 주식은 빨간색이 상승/수익 */
.highlight-minus { color: #3b82f6; } /* 주식은 파란색이 하락/손실 */
table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 14px; }
th, td { padding: 14px 12px; border-bottom: 1px solid #e2e8f0; text-align: center; }
th { background: #f8fafc; color: #475569; font-weight: 600; position: sticky; top: 0; }
tr:hover { background-color: #f1f5f9; }
.buy { color: #ef4444; font-weight: bold; }
.sell { color: #3b82f6; font-weight: bold; }
.plus { color: #ef4444; font-weight: bold; }
.minus { color: #3b82f6; font-weight: bold; }
.reason { text-align: left; font-size: 13px; color: #475569; background: #fafaf9; }
</style>
</head>
<body>
<h1>📊 ATRADE 일일 운용 리포트 (${summary.type} : $dateStr $timeStr)</h1>
<div class="tab">
<button class="tablinks" onclick="openTab(event, 'TabSummary')" id="defaultOpen">📌 당일 요약 대시보드</button>
<button class="tablinks" onclick="openTab(event, 'TabHoldings')">💼 현재 보유 잔고</button>
<button class="tablinks" onclick="openTab(event, 'TabTrades')">📝 거래 내역 상세</button>
</div>
<div id="TabSummary" class="tabcontent">
<div class="dashboard-header">
<div>
<div style="font-size: 14px; color: #94a3b8;">최종 운용 자산</div>
<div class="main-asset">${String.format("%,d", summary.endAsset)} 원</div>
</div>
<div class="asset-change ${if (summary.dailyAssetChange >= 0) "bg-plus" else "bg-minus"}">
오늘 수익: ${if (summary.dailyAssetChange > 0) "+" else ""}${String.format("%,d", summary.dailyAssetChange)}
</div>
</div>
<div class="grid-container">
<div class="stat-card">
<h3>🔄 금일 주문 활동</h3>
<p>매수 ${stats.buyOrderCount}회 / 매도 ${stats.sellOrderCount}회</p>
</div>
<div class="stat-card">
<h3>🎯 청산 상태 (진입 기준)</h3>
<p><span style="color:#22c55e;">완료 ${stats.completedCycles}건</span> / <span style="color:#f59e0b;">미완료 ${stats.pendingCycles}건</span></p>
</div>
<div class="stat-card">
<h3>💸 거래당 평균 수익</h3>
<p class="${if (stats.avgRealizedProfit >= 0) "plus" else "minus"}">
${String.format("%,d", stats.avgRealizedProfit)} 원 (${String.format("%.2f", stats.avgRealizedRate)}%)
</p>
</div>
</div>
<div class="grid-container">
<div class="stat-card" style="border-left: 4px solid #ef4444;">
<h3>💰 총 실현 손익 (확정)</h3>
<p class="${if (stats.totalRealizedProfit >= 0) "plus" else "minus"}">
${if (stats.totalRealizedProfit > 0) "+" else ""}${String.format("%,d", stats.totalRealizedProfit)}
</p>
</div>
<div class="stat-card">
<h3>🏆 오늘의 BEST</h3>
<p>${stats.bestTradeName} <span style="font-size:15px; color:#ef4444;">(+${String.format("%,d", stats.bestTradeProfit)})</span></p>
</div>
<div class="stat-card">
<h3>🏦 발생 제비용</h3>
<p>${String.format("%,d", summary.todayFees)} 원</p>
</div>
</div>
</div>
<div id="TabHoldings" class="tabcontent">
<table>
<thead><tr><th>종목명</th><th>보유수량</th><th>매입단가</th><th>현재가</th><th>평가금액</th><th>수익률</th><th>평가손익</th></tr><th>OHLC</th></thead>
<tbody>$holdingsHtml</tbody>
</table>
</div>
<div id="TabTrades" class="tabcontent">
<table>
<thead><tr><th>구분</th><th>종목명</th><th>체결시각</th><th>소요시간/상태</th><th>체결량</th><th>체결단가</th><th>수익률</th><th>수익금(실현/평가)</th><th>OHLC</th></tr></thead>
<tbody>$tradesHtml</tbody>
</table>
</div>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) tabcontent[i].style.display = "none";
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) tablinks[i].className = tablinks[i].className.replace(" active", "");
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
document.getElementById("defaultOpen").click();
</script>
</body>
</html>
""".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())
}
}
}