package report import io.ktor.utils.io.core.String 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 service.InvestmentGrade import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime 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 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] 새로운 설정 변경 이력이 저장되었습니다.") } } /** * [1] 자산 현황 및 보유 종목 스냅샷 저장 * isClose 파라미터가 true로 들어오면 모든 기록을 마치고 자동으로 일간 리포트를 생성합니다. */ override fun recordAssetSnapshot( type: SnapshotType, balance: UnifiedBalance, remark: String? ) { transaction(DatabaseFactory.reportDb) { val todayDate = LocalDate.now().toString() // 💡 [핵심 로직] 스냅샷 타입 자동 결정 val actualSnapshotType = if (type == SnapshotType.END) { SnapshotType.END } else { // 오늘 날짜로 기록된 START 스냅샷이 있는지 검사 val hasStartToday = AssetSnapshotTable.select { (AssetSnapshotTable.date eq todayDate) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name) }.limit(1).count() > 0 // 없으면 START, 이미 있으면 MIDDLE로 지정 if (hasStartToday) SnapshotType.MIDDLE else SnapshotType.START } // 1. 당시의 주요 설정값 백업 (기존 대표님 코드 유지) 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 configJson = 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. 자산 마스터 데이터 저장 (실제 결정된 actualSnapshotType 사용) val snapshotId = AssetSnapshotTable.insert { it[date] = todayDate it[snapshotType] = actualSnapshotType.name it[totalAsset] = balance.totalAsset.toLongOrNull() ?: 0L it[totalProfitRate] = balance.totalProfitRate.toDoubleOrNull() ?: 0.0 it[deposit] = balance.deposit.toLongOrNull() ?: 0L it[appliedConfigJson] = configJson it[this.remark] = remark ?: if (actualSnapshotType == SnapshotType.MIDDLE) "장중 상태 기록" else "" }[AssetSnapshotTable.id] // 3. 보유 종목 리스트 일괄 저장 (Batch Insert) if (balance.getHolldings().isNotEmpty()) { SnapshotHoldingsTable.batchInsert(balance.getHolldings()) { stock -> this[SnapshotHoldingsTable.snapshotId] = snapshotId this[SnapshotHoldingsTable.code] = stock.code this[SnapshotHoldingsTable.name] = stock.name // 포지션 ID 매핑: 메모리 맵에 없으면 신규 발급 val posId = activePositions.getOrPut(stock.code) { "${stock.code}_${System.currentTimeMillis()}" } this[SnapshotHoldingsTable.positionId] = posId 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.toLongOrNull() ?: 0L this[SnapshotHoldingsTable.isDomestic] = stock.isDomestic this[SnapshotHoldingsTable.isTodayEntry] = stock.isTodayEntry } } println("📊 [Report] ${actualSnapshotType.name} 자산 스냅샷 저장 완료 (보유종목: ${balance.getHolldings().size}개)") // 💡 4. 마감 플래그: END일 때만 리포트 생성 (MIDDLE은 데이터만 적재하고 패스) if (actualSnapshotType == SnapshotType.END) { println("🔔 [Report] 장 마감(END) 확인. 일간 매매 리포트 생성을 시작합니다.") generateDailyLocalReport() } } } 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) } } }