atrade/src/main/kotlin/report/TradingReportManager.kt
2026-05-13 11:37:57 +09:00

616 lines
32 KiB
Kotlin

package report
import io.ktor.utils.io.core.String
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import report.database.*
import model.*
import network.KisTradeService
import service.InvestmentGrade
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import org.jetbrains.exposed.dao.id.IntIdTable
enum class SnapshotType { START, END, MIDDLE }
@Serializable
data class ConfigSnapshot(
val targetProfit: Double, // 목표 손절 기준울
val stopLoss: Double, // 손절 기준율
val maxBudget: Double, // 최대 투자 금액
val guideScore: Double, // 최소 기준 점수
val descMinMax: String, // 주식 금액 필터링 최소,최대 금액
val grades : String, // 판장 등급별(GRADE5~GRADE1) 호가처리,세금 (0.33) + 손절((기준율 * 등급별 비율) ,최대 투자 금액(maxBudget * 등급 비율)
)
interface TradingReportService {
fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String? = null)
// 💡 [신규] 설정값이 변경될 때 호출할 함수
fun recordConfigChange()
fun closePositionCycle(stockCode: String)
fun recordTradeDecision(orderNo: String,
stockCode: String,
stockName: String,
isBuy: Boolean,
orderQty: Int,
reason: String,
holdingAvgPrice: Double = 0.0,
decision: TradingDecision? = null)
fun updateExecution(orderNo: String, execPrice: Double, execQty: Int, execTime: String? = null)
fun generateDailyLocalReport()
}
object TradingReportManager : TradingReportService {
// 💡 [핵심] 당일 재진입(단타) 포지션 꼬임 방지용 상태 관리 메모리 맵
// Key: 종목코드, Value: 현재 활성화된 Position ID
private val activePositions = mutableMapOf<String, String>()
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
if (!KisSession.tradeConfig.useAutoRepost) {
return
}
CoroutineScope(Dispatchers.IO).launch {
val todayDate = LocalDate.now().toString()
// 1. 중복 없는 전체 종목 코드 리스트 추출
val allCodes = mutableSetOf<String>()
val holdings = balance.getHoldings()
allCodes.addAll(holdings.map { it.code })
// 거래 내역 테이블에서 당일 거래된 종목 코드 추가
val tradedCodes = transaction(DatabaseFactory.reportDb) {
TradeHistoryTable.select { TradeHistoryTable.orderTime like "$todayDate%" }
.map { it[TradeHistoryTable.stockCode] }
}
allCodes.addAll(tradedCodes)
// 2. [핵심] 시세 데이터 일괄 병렬 조회 (Cache 구성)
val priceCache = mutableMapOf<String, CandleData?>()
if (type == SnapshotType.END) {
allCodes.chunked(10).forEach { batch -> // API 부하 조절을 위해 10개씩 묶음 처리 가능
batch.map { code ->
async {
code to KisTradeService.fetchPeriodChartData(code, "D", true).getOrNull()?.lastOrNull()
}
}.awaitAll().forEach { (code, data) ->
priceCache[code] = data
}
delay(200) // API 초당 호출 제한(TPS) 준수
}
}
transaction(DatabaseFactory.reportDb) {
// 1. 자산 스냅샷 저장
val snapshotId = AssetSnapshotTable.insertAndGetId {
it[AssetSnapshotTable.date] = todayDate
it[AssetSnapshotTable.snapshotType] = type.name
// 기존 데이터 유지 부분
it[AssetSnapshotTable.totalAsset] = balance.totalAsset.replace(",", "").toLongOrNull() ?: 0L
it[AssetSnapshotTable.totalProfitRate] =
balance.totalProfitRate.replace("%", "").toDoubleOrNull() ?: 0.0
it[AssetSnapshotTable.deposit] =
balance.deposit.replace(",", "").toLongOrNull() ?: 0L // 👈 [추가] 에러 원인 해결!
// 신규 리포트용 데이터 부분
it[AssetSnapshotTable.dailyAssetChange] =
balance.dailyAssetChange.replace(",", "").toLongOrNull() ?: 0L
it[AssetSnapshotTable.todayFees] = balance.todayFees.replace(",", "").toLongOrNull() ?: 0L
// it[AssetSnapshotTable.appliedConfigJson] = "{}" // 👈 [추가] 스키마상 필수로 지정되어 있어 빈 값이라도 넣어줍니다.
it[AssetSnapshotTable.remark] = remark
}.value
// 2. 보유 종목 스냅샷 저장
if (balance.getHoldings().isNotEmpty()) {
SnapshotHoldingsTable.batchInsert(balance.getHoldings()) { stock ->
this[SnapshotHoldingsTable.snapshotId] = snapshotId
this[SnapshotHoldingsTable.code] = stock.code
this[SnapshotHoldingsTable.name] = stock.name
this[SnapshotHoldingsTable.quantity] = stock.quantity.toIntOrNull() ?: 0
this[SnapshotHoldingsTable.avgPrice] = stock.avgPrice.toDoubleOrNull() ?: 0.0
this[SnapshotHoldingsTable.currentPrice] = stock.currentPrice.toDoubleOrNull() ?: 0.0
this[SnapshotHoldingsTable.profitRate] = stock.profitRate.toDoubleOrNull() ?: 0.0
this[SnapshotHoldingsTable.evalAmount] = stock.evalAmount.replace(",", "").toLongOrNull() ?: 0L
this[SnapshotHoldingsTable.isDomestic] = stock.isDomestic
this[SnapshotHoldingsTable.isTodayEntry] = stock.isTodayEntry
}
}
val startAsset = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq todayDate) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()
?.get(AssetSnapshotTable.totalAsset)
?: balance.totalAsset.replace(",", "").toLongOrNull() ?: 0L
// 3. 🚀 요약 데이터 Raw 패키징
val summaryData = LocalReportGenerator.RawSummaryData(
type = type.name,
startAsset = startAsset,
endAsset = balance.totalAsset.replace(",", "").toLongOrNull() ?: 0L,
dailyAssetChange = balance.dailyAssetChange.replace(",", "").toLongOrNull() ?: 0L,
todayFees = balance.todayFees.replace(",", "").toLongOrNull() ?: 0L,
totalProfitRate = balance.totalProfitRate.replace("%", "").toDoubleOrNull() ?: 0.0
)
// 4. 🚀 보유 잔고 데이터 Raw 패키징 (수익률 계산은 Generator에 위임)
val holdingLogs = balance.getHoldings().map { stock ->
var o = 0.0;
var h = 0.0;
var l = 0.0
var c = 0.0
// 💡 장 마감 리포트일 때만 시세 API 호출
if (type == SnapshotType.END) {
runBlocking {
priceCache[stock.code]?.let {
o = it.stck_oprc.toDoubleOrNull() ?: 0.0
h = it.stck_hgpr.toDoubleOrNull() ?: 0.0
l = it.stck_lwpr.toDoubleOrNull() ?: 0.0
c = it.stck_prpr.toDoubleOrNull() ?: 0.0
}
}
}
LocalReportGenerator.RawHoldingData(
stockName = stock.name,
quantity = stock.quantity.toIntOrNull() ?: 0,
avgPrice = stock.avgPrice.toDoubleOrNull() ?: 0.0,
currentPrice = stock.currentPrice.toDoubleOrNull() ?: 0.0,
evalAmount = stock.evalAmount.replace(",", "").toLongOrNull() ?: 0L,
openPrice = o, highPrice = h, lowPrice = l, closePrice = c,
)
}
// 5. 🚀 당일 거래 내역 Raw 패키징
val tradeLogs = TradeHistoryTable.select {
TradeHistoryTable.orderTime like "$todayDate%"
}.orderBy(TradeHistoryTable.orderTime to SortOrder.ASC).map { row ->
val code = row[TradeHistoryTable.stockCode]
var o = 0.0;
var h = 0.0;
var l = 0.0;
var c = 0.0
if (type == SnapshotType.END) {
runBlocking {
priceCache[code]?.let {
o = it.stck_oprc.toDoubleOrNull() ?: 0.0
h = it.stck_hgpr.toDoubleOrNull() ?: 0.0
l = it.stck_lwpr.toDoubleOrNull() ?: 0.0
c = it.stck_prpr.toDoubleOrNull() ?: 0.0
}
}
}
// 주의: 시간순(ASC)으로 정렬해서 넘겨야 제너레이터가 시간 흐름대로 묶기 편합니다.
val tradeId = row[TradeHistoryTable.id].value
val rawExecutions =
ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.map { exec ->
LocalReportGenerator.RawExecutionData(
price = exec[ExecutionDetailsTable.price],
quantity = exec[ExecutionDetailsTable.quantity],
execTime = exec[ExecutionDetailsTable.execTime]
)
}
// 매도일 경우에만 매수 단가 추적 (DB 조회가 필요하므로 이 작업만 매니저가 수행)
val isBuy = row[TradeHistoryTable.isBuy]
val resolvedBuyPrice =
if (!isBuy) findBuyPrice(row[TradeHistoryTable.stockCode], tradeId, todayDate) else 0.0
LocalReportGenerator.RawTradeData(
stockCode = row[TradeHistoryTable.stockCode], // 💡 [추가] 제너레이터가 종목별로 묶을 수 있도록 코드값 전달
stockName = row[TradeHistoryTable.stockName],
isBuy = isBuy,
status = row[TradeHistoryTable.status],
orderTime = row[TradeHistoryTable.orderTime],
executions = rawExecutions,
currentPrice = row[TradeHistoryTable.currentPrice] ?: 0.0,
resolvedBuyPrice = resolvedBuyPrice,
investmentGrade = row[TradeHistoryTable.investmentGrade] ?: "-",
reason = row[TradeHistoryTable.reason] ?: "",
aiScore = row[TradeHistoryTable.aiScore] ?: 0.0,
openPrice = o,
highPrice = h,
lowPrice = l,
closePrice = c,
)
}
// 6. 코루틴 기반 제너레이터 호출
LocalReportGenerator.generateAndOpenAsync(summaryData, holdingLogs, tradeLogs)
}
}
}
override fun recordConfigChange() {
transaction(DatabaseFactory.reportDb) {
val now = LocalDateTime.now()
// 1. 현재 설정값을 JSON으로 묶기 (대표님이 정리하신 코드 그대로 사용)
var buffer = StringBuffer()
arrayOf(
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND, InvestmentGrade.LEVEL_2_HIGH_RISK, InvestmentGrade.LEVEL_1_SPECULATIVE
).forEach { grade ->
buffer.appendLine("${grade.name}")
.appendLine(" 매수 목표 : -${KisSession.config.getValues(grade.buyGuide)}호가, 목표 수익:${KisSession.config.getValues(ConfigIndex.TAX_INDEX) + (KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))}, 최대 투자:${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * KisSession.config.getValues(grade.allocationRate)}")
}
val currentConfigJson = Json.encodeToString(
ConfigSnapshot(
targetProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) ?: 0.0,
stopLoss = KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE) ?: 0.0,
maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) ?: 0.0,
guideScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) ?: 0.0,
descMinMax = "종목 금액 필터 MIN:${KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) ?: 0.0} ~ MAX:${KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) ?: 0.0}",
grades = buffer.toString()
)
)
// 2. DB에서 가장 최근에 저장된 설정 이력 가져오기
val lastConfigRecord = ConfigHistoryTable.selectAll()
.orderBy(ConfigHistoryTable.id to SortOrder.DESC)
.limit(1)
.singleOrNull()
if (lastConfigRecord != null) {
val lastUpdatedStr = lastConfigRecord[ConfigHistoryTable.updatedAt]
val lastUpdatedTime = LocalDateTime.parse(lastUpdatedStr)
// 3. 마지막 수정 시간과 현재 시간의 차이 계산
val minutesDiff = java.time.Duration.between(lastUpdatedTime, now).toMinutes()
if (minutesDiff <= 10) {
// 💡 [핵심] 10분 이내의 변경이면 새로운 row를 만들지 않고 기존 row 덮어쓰기 (Update)
ConfigHistoryTable.update({ ConfigHistoryTable.id eq lastConfigRecord[ConfigHistoryTable.id] }) {
it[updatedAt] = now.toString() // 시간은 최신으로 갱신
it[configJson] = currentConfigJson
}
println("⚙️ [Report] 설정 연속 변경 감지 (10분 이내). 기존 이력을 덮어씁니다.")
return@transaction
}
}
// 4. 10분이 지났거나, 아예 최초 저장이라면 새로운 row 생성 (Insert)
ConfigHistoryTable.insert {
it[updatedAt] = now.toString()
it[configJson] = currentConfigJson
}
println("⚙️ [Report] 새로운 설정 변경 이력이 저장되었습니다.")
}
}
// ==========================================
// 3. 내부 유틸리티 함수
// ==========================================
private fun calculateTimeTaken(orderTimeStr: String, lastExecTimeStr: String?, status: String): String {
if (lastExecTimeStr == null) return "미체결 (진행중)"
return try {
val orderTime = LocalDateTime.parse(orderTimeStr)
val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
val duration = java.time.Duration.between(orderTime, lastExecTime)
val timeString = if (duration.toMinutes() > 0) {
"${duration.toMinutes()}${duration.seconds % 60}"
} else {
"${duration.toMillis() / 1000.0}"
}
if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
} catch (e: Exception) {
"계산 불가"
}
}
private fun findBuyPrice(stockCode: String, currentTradeId: Int, todayDate: String): Double {
// 1차 방어선: 당일 체결된 매수 내역 중 확정된 purchasePrice가 있는지 확인
val recentBuyTrade = TradeHistoryTable.select {
(TradeHistoryTable.stockCode eq stockCode) and
(TradeHistoryTable.isBuy eq true) and
(TradeHistoryTable.id less currentTradeId)
}.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
if (recentBuyTrade != null) {
val pPrice = recentBuyTrade[TradeHistoryTable.purchasePrice]
if (pPrice > 0.0) return pPrice // 💡 새로 추가된 확정 단가 최우선 사용
// 만약 없다면 체결 내역에서 계산 (기존 방식)
val buyExecutions = ExecutionDetailsTable.select {
ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id]
}.toList()
val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
if (buyQty > 0) {
return buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
}
}
// 2차 방어선: 당일 아침 START 스냅샷
val startSnapshotId = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq todayDate) and
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.limit(1).singleOrNull()?.get(AssetSnapshotTable.id)
if (startSnapshotId != null) {
val startAvgPrice = SnapshotHoldingsTable.select {
(SnapshotHoldingsTable.snapshotId eq startSnapshotId) and
(SnapshotHoldingsTable.code eq stockCode)
}.singleOrNull()?.get(SnapshotHoldingsTable.avgPrice)
if (startAvgPrice != null && startAvgPrice > 0) return startAvgPrice
}
// 3차 방어선: 레거시 보유 평단가
return TradeHistoryTable.select { TradeHistoryTable.id eq currentTradeId }
.singleOrNull()?.get(TradeHistoryTable.holdingAvgPrice) ?: 0.0
}
override fun recordTradeDecision(orderNo: String,
stockCode: String,
stockName: String,
isBuy: Boolean,
orderQty: Int,
reason: String,
holdingAvgPrice: Double,
decision: TradingDecision?) {
transaction(DatabaseFactory.reportDb) {
TradeHistoryTable.insert {
it[this.orderNo] = orderNo
it[this.stockCode] = stockCode
it[this.stockName] = stockName
it[this.orderQty] = orderQty // 처음에 주문한 총 수량 기억
it[orderTime] = LocalDateTime.now().toString()
it[this.isBuy] = isBuy
it[status] = "ORDERED"
it[this.reason] = reason
it[this.holdingAvgPrice] = holdingAvgPrice
// 💡 decision이 있으면 AI 데이터를 넣고, 없으면(기계적 매도) null로 깔끔하게 비움
it[currentPrice] = decision?.currentPrice
it[aiScore] = decision?.confidence
it[newsScore] = decision?.newsScore
it[systemScore] = decision?.systemScore
it[financialScore] = decision?.financialScore
it[technicalScore] = decision?.technicalScore
it[investmentGrade] = decision?.investmentGrade?.name
}
}
}
/**
* [3] 실제 체결 내역 업데이트 및 완전 체결(COMPLETED) 자동 판별
*/
override fun updateExecution(orderNo: String, execPrice: Double, execQty: Int, execTime: String?) {
transaction(DatabaseFactory.reportDb) {
val tradeRecord = TradeHistoryTable.select { TradeHistoryTable.orderNo eq orderNo }.singleOrNull()
if (tradeRecord != null) {
val internalTradeId = tradeRecord[TradeHistoryTable.id]
val code = tradeRecord[TradeHistoryTable.stockCode]
val isBuy = tradeRecord[TradeHistoryTable.isBuy]
val targetOrderQty = tradeRecord[TradeHistoryTable.orderQty]
// [포지션 라이프사이클 - 시작/레거시 처리]
if (isBuy && !activePositions.containsKey(code)) {
activePositions[code] = "${code}_${System.currentTimeMillis()}"
} else if (!isBuy && !activePositions.containsKey(code)) {
activePositions[code] = "${code}_legacy_position"
}
// 1. 체결 내역 Insert
val finalExecTime = execTime ?: LocalDateTime.now().toString()
ExecutionDetailsTable.insert {
it[tradeId] = internalTradeId
it[this.execTime] = finalExecTime
it[price] = execPrice
it[quantity] = execQty
}
// 2. 주문 상태 (PARTIAL / COMPLETED) 자동 판별
val totalExecutedQty = ExecutionDetailsTable
.select { ExecutionDetailsTable.tradeId eq internalTradeId }
.sumOf { it[ExecutionDetailsTable.quantity] }
TradeHistoryTable.update({ TradeHistoryTable.id eq internalTradeId }) {
it[status] = if (totalExecutedQty >= targetOrderQty) "COMPLETED" else "PARTIAL"
}
// 💡 3. [포지션 사이클 종료 처리 - 자율 계산 로직]
// 매도(SELL) 체결이 일어났을 때만, DB를 바탕으로 현재 남은 잔고를 스스로 계산합니다.
if (!isBuy) {
val todayDate = LocalDate.now().toString()
// A. 오늘 아침(START) 기준 해당 종목의 기초 잔고 가져오기 (테스트 중 데이터가 없으면 0으로 처리)
val startSnapshotId = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq todayDate) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.limit(1).singleOrNull()?.get(AssetSnapshotTable.id)
val startQty = if (startSnapshotId != null) {
SnapshotHoldingsTable.select {
(SnapshotHoldingsTable.snapshotId eq startSnapshotId) and (SnapshotHoldingsTable.code eq code)
}.singleOrNull()?.get(SnapshotHoldingsTable.quantity) ?: 0
} else 0
// B. 오늘 하루 동안 발생한 모든 매수 체결량(BUY) 합산
val todayBuyQty = (TradeHistoryTable innerJoin ExecutionDetailsTable)
.slice(ExecutionDetailsTable.quantity.sum())
.select {
(TradeHistoryTable.stockCode eq code) and
(TradeHistoryTable.isBuy eq true) and
(TradeHistoryTable.orderTime like "$todayDate%")
}.singleOrNull()?.get(ExecutionDetailsTable.quantity.sum()) ?: 0
// C. 오늘 하루 동안 발생한 모든 매도 체결량(SELL) 합산
val todaySellQty = (TradeHistoryTable innerJoin ExecutionDetailsTable)
.slice(ExecutionDetailsTable.quantity.sum())
.select {
(TradeHistoryTable.stockCode eq code) and
(TradeHistoryTable.isBuy eq false) and
(TradeHistoryTable.orderTime like "$todayDate%")
}.singleOrNull()?.get(ExecutionDetailsTable.quantity.sum()) ?: 0
// D. 리포팅 엔진이 추정한 현재 최종 잔고 = (기초 잔고 + 오늘 매수량 - 오늘 매도량)
val currentEstimatedQty = startQty + todayBuyQty - todaySellQty
// 추정 잔고가 0 이하가 되면 전량 매도로 간주하고 사이클을 닫음!
if (currentEstimatedQty <= 0) {
val closedPos = activePositions.remove(code)
if (closedPos != null) {
println("🏁 [Report] DB 계산결과 $code 종목 전량 매도 확인(잔고 0). 매매 사이클($closedPos) 내부 자동 종료.")
}
}
}
}
}
}
override fun closePositionCycle(stockCode: String) {
val closedPositionId = activePositions.remove(stockCode)
if (closedPositionId != null) {
println("🏁 [Position] $stockCode 종목 전량 매도로 매매 사이클($closedPositionId)이 종료 및 초기화되었습니다.")
}
}
/**
* [4] 장 마감 로컬 리포트(HTML) 자동 생성
*/
override fun generateDailyLocalReport() {
// transaction(DatabaseFactory.reportDb) {
// val today = LocalDate.now().toString()
//
// val startAsset = AssetSnapshotTable.select {
// (AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
// }.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: 0L
//
// val endAsset = AssetSnapshotTable.select {
// (AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.END.name)
// }.orderBy(AssetSnapshotTable.id to SortOrder.DESC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: startAsset
//
// // 💡 [변경] isBuy == false 필터를 제거하여 금일 발생한 '모든' 주문 내역을 가져옵니다.
// val allTrades = TradeHistoryTable.select {
// (TradeHistoryTable.orderTime like "$today%")
// }.orderBy(TradeHistoryTable.orderTime to SortOrder.DESC).toList()
//
// val tradeLogs = allTrades.map { row ->
// val tradeId = row[TradeHistoryTable.id]
// val stockCode = row[TradeHistoryTable.stockCode]
// val isBuy = row[TradeHistoryTable.isBuy]
// val status = row[TradeHistoryTable.status]
// val orderTimeStr = row[TradeHistoryTable.orderTime] // 예: "2024-05-20T09:10:00.123"
//
// // 1. 체결 상세 정보 가져오기
// val executions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.toList()
// val execQty = executions.sumOf { it[ExecutionDetailsTable.quantity] }
//
// // VWAP 평균 체결가 산출
// val avgPrice = if (execQty > 0) {
// executions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / execQty
// } else {
// row[TradeHistoryTable.currentPrice] ?: 0.0
// }
//
// // 💡 2. 주문부터 마지막 체결까지 소요된 시간 계산 로직
// val lastExecTimeStr = executions.maxByOrNull { it[ExecutionDetailsTable.execTime] }?.get(ExecutionDetailsTable.execTime)
//
// val timeTakenStr = if (lastExecTimeStr != null) {
// try {
// val orderTime = LocalDateTime.parse(orderTimeStr)
// val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
// val duration = Duration.between(orderTime, lastExecTime)
//
// val timeString = if (duration.toMinutes() > 0) {
// "${duration.toMinutes()}분 ${duration.seconds % 60}초"
// } else {
// "${duration.toMillis() / 1000.0}초"
// }
//
// // 아직 상태가 COMPLETED가 아니면 미완료 표시 (현재 상태값이 ORDERED, PARTIAL 등일 경우)
// if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
// } catch (e: Exception) {
// "계산 불가"
// }
// } else {
// "미체결 (진행중)"
// }
//
// // 3. 수익률 및 수익금 계산 (매도일 때만)
// var calculatedProfitRate = 0.0
// var calculatedProfitAmount = 0L
//
// if (!isBuy) {
// // 과거 매수 단가 찾기 (1순위: 이전 매수 기록, 2순위: 아침 스냅샷)
// val recentBuyTrade = TradeHistoryTable.select {
// (TradeHistoryTable.stockCode eq stockCode) and
// (TradeHistoryTable.isBuy eq true) and
// (TradeHistoryTable.id less tradeId)
// }.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
//
// var avgBuyPrice = 0.0
// if (recentBuyTrade != null) {
// val buyExecutions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id] }.toList()
// val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
// if (buyQty > 0) avgBuyPrice = buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
// }
//
// // 2차 시도: 오늘 아침 START 스냅샷에 기록된 보유 평단가
// if (avgBuyPrice == 0.0) {
// val startHoldings = SnapshotHoldingsTable.innerJoin(AssetSnapshotTable).select {
// (SnapshotHoldingsTable.code eq stockCode) and
// (AssetSnapshotTable.date eq today) and
// (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
// }.singleOrNull()
// avgBuyPrice = startHoldings?.get(SnapshotHoldingsTable.avgPrice) ?: 0.0
// }
//
// // 💡 3차 시도 (궁극의 방어막): 리포팅 도입 이전부터 들고 있던 레거시 종목일 경우
// // 매도 주문 당시에 DB에 백업해두었던 증권사 보유 평단가를 그대로 사용합니다.
// if (avgBuyPrice == 0.0) {
// avgBuyPrice = row[TradeHistoryTable.holdingAvgPrice]
// }
//
// // --- [C] 최종 수익률 계산 ---
// if (avgBuyPrice > 0.0) {
// calculatedProfitRate = ((avgPrice - avgBuyPrice) / avgBuyPrice) * 100
// calculatedProfitAmount = ((avgPrice - avgBuyPrice) * execQty).toLong()
// } else {
// calculatedProfitRate = 0.0
// calculatedProfitAmount = 0L
// }
// }
//
// // 4. DTO 조립
// LocalReportGenerator.TradeDetailData(
// isBuy = isBuy,
// stockName = row[TradeHistoryTable.stockName],
// orderTime = orderTimeStr,
// timeTaken = timeTakenStr,
// execQty = execQty,
// avgPrice = avgPrice.toLong(),
// profitRate = calculatedProfitRate,
// profitAmount = calculatedProfitAmount,
// reason = row[TradeHistoryTable.reason],
// investmentGrade = row[TradeHistoryTable.investmentGrade],
// aiScore = row[TradeHistoryTable.aiScore]
// )
// }
//
// LocalReportGenerator.generateAndOpen(startAsset, endAsset, tradeLogs)
// }
}
}