atrade/src/main/kotlin/report/TradingReportManager.kt

616 lines
32 KiB
Kotlin
Raw Normal View History

2026-04-16 15:48:23 +09:00
package report
import io.ktor.utils.io.core.String
2026-04-17 18:08:53 +09:00
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
2026-04-16 15:48:23 +09:00
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.*
2026-04-17 18:08:53 +09:00
import network.KisTradeService
2026-04-16 15:48:23 +09:00
import service.InvestmentGrade
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
2026-04-17 18:08:53 +09:00
import org.jetbrains.exposed.dao.id.IntIdTable
2026-04-16 15:48:23 +09:00
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>()
2026-04-17 18:08:53 +09:00
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
2026-05-13 11:37:57 +09:00
if (!KisSession.tradeConfig.useAutoRepost) {
return
}
2026-04-17 18:08:53 +09:00
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)
}
}
}
2026-04-16 15:48:23 +09:00
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] 새로운 설정 변경 이력이 저장되었습니다.")
}
}
2026-04-17 18:08:53 +09:00
// ==========================================
// 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)
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
val timeString = if (duration.toMinutes() > 0) {
"${duration.toMinutes()}${duration.seconds % 60}"
2026-04-16 15:48:23 +09:00
} else {
2026-04-17 18:08:53 +09:00
"${duration.toMillis() / 1000.0}"
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
} catch (e: Exception) {
"계산 불가"
}
}
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
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
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
}
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
// 2차 방어선: 당일 아침 START 스냅샷
val startSnapshotId = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq todayDate) and
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.limit(1).singleOrNull()?.get(AssetSnapshotTable.id)
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
if (startSnapshotId != null) {
val startAvgPrice = SnapshotHoldingsTable.select {
(SnapshotHoldingsTable.snapshotId eq startSnapshotId) and
(SnapshotHoldingsTable.code eq stockCode)
}.singleOrNull()?.get(SnapshotHoldingsTable.avgPrice)
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
if (startAvgPrice != null && startAvgPrice > 0) return startAvgPrice
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
// 3차 방어선: 레거시 보유 평단가
return TradeHistoryTable.select { TradeHistoryTable.id eq currentTradeId }
.singleOrNull()?.get(TradeHistoryTable.holdingAvgPrice) ?: 0.0
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
2026-04-16 15:48:23 +09:00
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() {
2026-04-17 18:08:53 +09:00
// 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)
// }
2026-04-16 15:48:23 +09:00
}
}