...
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
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
|
||||
@@ -8,11 +15,12 @@ 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 }
|
||||
|
||||
@@ -50,6 +58,182 @@ object TradingReportManager : TradingReportService {
|
||||
// Key: 종목코드, Value: 현재 활성화된 Position ID
|
||||
private val activePositions = mutableMapOf<String, String>()
|
||||
|
||||
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
|
||||
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()
|
||||
@@ -108,98 +292,72 @@ object TradingReportManager : TradingReportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [1] 자산 현황 및 보유 종목 스냅샷 저장
|
||||
* isClose 파라미터가 true로 들어오면 모든 기록을 마치고 자동으로 일간 리포트를 생성합니다.
|
||||
*/
|
||||
|
||||
override fun recordAssetSnapshot(
|
||||
type: SnapshotType,
|
||||
balance: UnifiedBalance,
|
||||
remark: String?
|
||||
) {
|
||||
transaction(DatabaseFactory.reportDb) {
|
||||
val todayDate = LocalDate.now().toString()
|
||||
// ==========================================
|
||||
// 3. 내부 유틸리티 함수
|
||||
// ==========================================
|
||||
|
||||
// 💡 [핵심 로직] 스냅샷 타입 자동 결정
|
||||
val actualSnapshotType = if (type == SnapshotType.END) {
|
||||
SnapshotType.END
|
||||
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 {
|
||||
// 오늘 날짜로 기록된 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()
|
||||
"${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,
|
||||
@@ -330,126 +488,126 @@ object TradingReportManager : TradingReportService {
|
||||
* [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)
|
||||
}
|
||||
// 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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user