166 lines
9.1 KiB
Kotlin
166 lines
9.1 KiB
Kotlin
|
|
package report
|
||
|
|
|
||
|
|
import java.awt.Desktop
|
||
|
|
import java.io.File
|
||
|
|
import java.time.LocalDate
|
||
|
|
import java.time.LocalDateTime
|
||
|
|
import java.time.format.DateTimeFormatter
|
||
|
|
|
||
|
|
object LocalReportGenerator {
|
||
|
|
|
||
|
|
// 💡 모든 거래 내역의 상세 지표를 담는 DTO
|
||
|
|
data class TradeDetailData(
|
||
|
|
val isBuy: Boolean, // 매수/매도 구분
|
||
|
|
val stockName: String,
|
||
|
|
val orderTime: String, // 주문 시각
|
||
|
|
val timeTaken: String, // 처리 완료 총 시간 또는 미완료 상태
|
||
|
|
val execQty: Int, // 처리량 (체결 수량)
|
||
|
|
val avgPrice: Long, // 체결 평균 단가
|
||
|
|
val profitRate: Double, // 수익률 (매도 시에만 유효)
|
||
|
|
val profitAmount: Long, // 수익금액 (매도 시에만 유효)
|
||
|
|
val reason: String,
|
||
|
|
val investmentGrade: String?,
|
||
|
|
val aiScore: Double?
|
||
|
|
)
|
||
|
|
|
||
|
|
fun generateAndOpen(startAsset: Long, endAsset: Long, tradeLogs: List<TradeDetailData>) {
|
||
|
|
val today = LocalDate.now().toString()
|
||
|
|
val profitAmount = endAsset - startAsset
|
||
|
|
val profitRate = if (startAsset > 0) (profitAmount.toDouble() / startAsset) * 100 else 0.0
|
||
|
|
|
||
|
|
val profitColor = if (profitAmount > 0) "#FF3B30" else if (profitAmount < 0) "#007AFF" else "#333333"
|
||
|
|
val profitSign = if (profitAmount > 0) "+" else ""
|
||
|
|
|
||
|
|
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||
|
|
|
||
|
|
// 1. 매매 상세 내역 HTML 행 생성
|
||
|
|
val tradeRowsHtml = if (tradeLogs.isEmpty()) {
|
||
|
|
"<tr><td colspan='8' style='padding: 30px; text-align: center; color: #999;'>금일 발생한 주문 내역이 없습니다.</td></tr>"
|
||
|
|
} else {
|
||
|
|
tradeLogs.joinToString("\n") { trade ->
|
||
|
|
// 매수(빨강), 매도(파랑) 뱃지
|
||
|
|
val typeBadge = if (trade.isBuy) "<span class='badge type-buy'>매수</span>" else "<span class='badge type-sell'>매도</span>"
|
||
|
|
|
||
|
|
// 시간 포맷팅
|
||
|
|
val orderTimeParsed = LocalDateTime.parse(trade.orderTime).format(timeFormatter)
|
||
|
|
|
||
|
|
// 수익률/수익금 처리 (매수일 경우 표시 안 함)
|
||
|
|
val rateText = if (trade.isBuy) "-" else "${String.format("%.2f", trade.profitRate)}%"
|
||
|
|
val rateColor = if (trade.isBuy || trade.profitRate == 0.0) "#333" else if (trade.profitRate > 0) "#FF3B30" else "#007AFF"
|
||
|
|
val amountText = if (trade.isBuy) "-" else "${String.format("%,d", trade.profitAmount)}원"
|
||
|
|
|
||
|
|
// 상태/시간 표시 처리
|
||
|
|
val timeStatusHtml = if (trade.timeTaken.contains("미완료")) {
|
||
|
|
"<span style='color: #FF9500; font-weight: bold;'>${trade.timeTaken}</span>"
|
||
|
|
} else {
|
||
|
|
"<span style='color: #888;'>${trade.timeTaken}</span>"
|
||
|
|
}
|
||
|
|
|
||
|
|
val gradeHtml = trade.investmentGrade?.let { "<span class='badge grade-$it'>$it</span>" } ?: ""
|
||
|
|
|
||
|
|
"""
|
||
|
|
<tr>
|
||
|
|
<td class="center">$typeBadge</td>
|
||
|
|
<td class="stock-name"><strong>${trade.stockName}</strong> $gradeHtml</td>
|
||
|
|
<td class="center">$orderTimeParsed</td>
|
||
|
|
<td class="center">$timeStatusHtml</td>
|
||
|
|
<td class="right">${String.format("%,d", trade.execQty)}주</td>
|
||
|
|
<td class="right">${String.format("%,d", trade.avgPrice)}원</td>
|
||
|
|
<td class="right rate" style="color: $rateColor;">$rateText</td>
|
||
|
|
<td class="right rate" style="color: $rateColor;">$amountText</td>
|
||
|
|
</tr>
|
||
|
|
<tr class="reason-row">
|
||
|
|
<td colspan="8">
|
||
|
|
<div class="reason-box">
|
||
|
|
<span class="reason-icon">💡</span>
|
||
|
|
<span class="reason-text"><strong>AI (${String.format("%.1f", trade.aiScore ?: 0.0)}점):</strong> ${trade.reason.replace("\n", " ")}</span>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
""".trimIndent()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. 전체 HTML 템플릿
|
||
|
|
val htmlTemplate = """
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="ko">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<title>ATRADE 일간 리포트 - $today</title>
|
||
|
|
<style>
|
||
|
|
:root { --red: #FF3B30; --blue: #007AFF; --bg: #f4f7f6; --text: #333; }
|
||
|
|
body { font-family: 'Pretendard', 'Apple SD Gothic Neo', sans-serif; background-color: var(--bg); color: var(--text); padding: 40px 20px; margin: 0; }
|
||
|
|
.container { max-width: 1000px; margin: 0 auto; background-color: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.05); }
|
||
|
|
.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 40px; text-align: center; }
|
||
|
|
.header h1 { margin: 0; font-size: 32px; font-weight: 800; }
|
||
|
|
.summary { padding: 40px; text-align: center; border-bottom: 1px solid #eee; }
|
||
|
|
.summary .profit { font-size: 54px; font-weight: 800; color: $profitColor; margin: 10px 0; }
|
||
|
|
.details { display: flex; justify-content: center; gap: 50px; margin-top: 25px; background: #f8f9fa; padding: 20px; border-radius: 12px; }
|
||
|
|
.details div { display: flex; flex-direction: column; }
|
||
|
|
.details span.label { font-size: 13px; color: #888; margin-bottom: 5px; }
|
||
|
|
.details span.value { font-size: 20px; font-weight: 700; color: #333; }
|
||
|
|
.table-container { padding: 40px; overflow-x: auto; }
|
||
|
|
table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 800px; }
|
||
|
|
th { padding: 12px; text-align: left; background-color: #f8f9fa; color: #555; border-bottom: 2px solid #ddd; }
|
||
|
|
th.right { text-align: right; }
|
||
|
|
th.center { text-align: center; }
|
||
|
|
td { padding: 14px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
||
|
|
td.right { text-align: right; font-weight: 500; }
|
||
|
|
td.center { text-align: center; }
|
||
|
|
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; color: #fff; margin-left: 4px; }
|
||
|
|
.type-buy { background-color: var(--red); }
|
||
|
|
.type-sell { background-color: var(--blue); }
|
||
|
|
.grade-S { background-color: #8A2BE2; } .grade-A { background-color: #FF9500; } .grade-B { background-color: #34C759; }
|
||
|
|
.reason-row td { padding: 0 12px 12px 12px; border-bottom: 2px solid #eee; }
|
||
|
|
.reason-box { background-color: #f8fcfd; border-left: 4px solid #34b3e4; padding: 10px 14px; border-radius: 0 6px 6px 0; font-size: 13px; color: #555; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="container">
|
||
|
|
<div class="header">
|
||
|
|
<h1>ATRADE 일간 매매 상세 리포트</h1>
|
||
|
|
</div>
|
||
|
|
<div class="summary">
|
||
|
|
<h2>오늘의 실현 손익 (END - START)</h2>
|
||
|
|
<p class="profit">$profitSign${String.format("%,d", profitAmount)}원 <span style="font-size: 32px;">($profitSign${String.format("%.2f", profitRate)}%)</span></p>
|
||
|
|
</div>
|
||
|
|
<div class="table-container">
|
||
|
|
<h3>📊 금일 주문 및 체결 내역 전체</h3>
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th class="center">구분</th>
|
||
|
|
<th>종목명</th>
|
||
|
|
<th class="center">주문시각</th>
|
||
|
|
<th class="center">소요시간/상태</th>
|
||
|
|
<th class="right">체결량</th>
|
||
|
|
<th class="right">평균단가</th>
|
||
|
|
<th class="right">수익률</th>
|
||
|
|
<th class="right">수익금</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
$tradeRowsHtml
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
""".trimIndent()
|
||
|
|
|
||
|
|
val directory = File("reports")
|
||
|
|
if (!directory.exists()) directory.mkdirs()
|
||
|
|
val reportFile = File(directory, "ATRADE_Report_$today.html")
|
||
|
|
|
||
|
|
try {
|
||
|
|
reportFile.writeText(htmlTemplate, Charsets.UTF_8)
|
||
|
|
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||
|
|
Desktop.getDesktop().browse(reportFile.toURI())
|
||
|
|
}
|
||
|
|
} catch (e: Exception) {
|
||
|
|
e.printStackTrace()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|