455 lines
24 KiB
Kotlin
455 lines
24 KiB
Kotlin
|
|
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<String, String>()
|
||
|
|
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|