503 lines
25 KiB
Kotlin
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())
|
|
}
|
|
}
|
|
} |