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() 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() 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() 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) // } } }