This commit is contained in:
lunaticbum 2026-04-17 18:08:53 +09:00
parent ed12d07bc2
commit 2d577300c3
9 changed files with 1023 additions and 479 deletions

View File

@ -9,6 +9,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
import report.TradingReportManager import report.TradingReportManager
import report.TradingReportService import report.TradingReportService
import report.database.AssetSnapshotTable import report.database.AssetSnapshotTable
import report.database.ConfigHistoryTable
import report.database.ExecutionDetailsTable import report.database.ExecutionDetailsTable
import report.database.SnapshotHoldingsTable import report.database.SnapshotHoldingsTable
import report.database.TradeHistoryTable import report.database.TradeHistoryTable
@ -139,6 +140,7 @@ object DatabaseFactory {
transaction(reportDb) { transaction(reportDb) {
SchemaUtils.createMissingTablesAndColumns( SchemaUtils.createMissingTablesAndColumns(
ConfigHistoryTable,
AssetSnapshotTable, AssetSnapshotTable,
SnapshotHoldingsTable, SnapshotHoldingsTable,
TradeHistoryTable, TradeHistoryTable,

View File

@ -1,7 +1,8 @@
package model package model
import report.TradingReportManager
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlin.math.abs
enum class ConfigIndex(val index : Int,val label : String) { enum class ConfigIndex(val index : Int,val label : String) {
@ -118,8 +119,9 @@ data class AppConfig(
return if (isSimulation) vtsAccountNo else realAccountNo return if (isSimulation) vtsAccountNo else realAccountNo
} }
var firstSet = mutableSetOf<ConfigIndex>()
fun setValues(index :ConfigIndex , value : Double) { fun setValues(index :ConfigIndex , value : Double) {
val oldValue = getValues(index)
when (index) { when (index) {
ConfigIndex.TAX_INDEX -> {FEES_AND_TAXRATE = value} ConfigIndex.TAX_INDEX -> {FEES_AND_TAXRATE = value}
ConfigIndex.PROFIT_INDEX -> {MINIMUM_NET_PROFIT = value} ConfigIndex.PROFIT_INDEX -> {MINIMUM_NET_PROFIT = value}
@ -155,6 +157,18 @@ data class AppConfig(
ConfigIndex.TAKE_PROFIT -> { take_profit = value > 0.1 } ConfigIndex.TAKE_PROFIT -> { take_profit = value > 0.1 }
ConfigIndex.MAX_HOLDING_COUNT -> { max_holding_count = value } ConfigIndex.MAX_HOLDING_COUNT -> { max_holding_count = value }
} }
if (firstSet.contains(index)) {
DatabaseFactory.saveConfig(KisSession.config)
TradingLogStore.addSettingLog(
index.label,
oldValue.toString(),
value.toString(),
"💾 저장됨: ${index.label} = ${getValues(index)}"
)
TradingReportManager.recordConfigChange()
} else {
firstSet.add(index)
}
} }
fun getValues(index :ConfigIndex) : Double { fun getValues(index :ConfigIndex) : Double {
return when (index) { return when (index) {
@ -196,9 +210,9 @@ data class AppConfig(
ConfigIndex.GRADE_3_ALLOCATIONRATE -> {GRADE_3_ALLOCATIONRATE} ConfigIndex.GRADE_3_ALLOCATIONRATE -> {GRADE_3_ALLOCATIONRATE}
ConfigIndex.GRADE_2_ALLOCATIONRATE -> {GRADE_2_ALLOCATIONRATE} ConfigIndex.GRADE_2_ALLOCATIONRATE -> {GRADE_2_ALLOCATIONRATE}
ConfigIndex.GRADE_1_ALLOCATIONRATE -> {GRADE_1_ALLOCATIONRATE} ConfigIndex.GRADE_1_ALLOCATIONRATE -> {GRADE_1_ALLOCATIONRATE}
ConfigIndex.LOSS_MAXRATE -> { loss_max} ConfigIndex.LOSS_MAXRATE -> { abs(loss_max) * -1}
ConfigIndex.LOSS_MINRATE -> { loss_min} ConfigIndex.LOSS_MINRATE -> { abs(loss_min) * -1}
ConfigIndex.LOSS_MAX_MONEY -> { loss_money } ConfigIndex.LOSS_MAX_MONEY -> { abs(loss_money) * -1}
ConfigIndex.STOP_LOSS -> {if(!stop_Loss) 0.0 else 1.0} ConfigIndex.STOP_LOSS -> {if(!stop_Loss) 0.0 else 1.0}
ConfigIndex.TAKE_PROFIT -> {if(!take_profit) 0.0 else 1.0} ConfigIndex.TAKE_PROFIT -> {if(!take_profit) 0.0 else 1.0}
ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()} ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()}

View File

@ -3,38 +3,43 @@ package model
import AutoTradeItem import AutoTradeItem
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class StockBalanceResponse( data class StockBalanceResponse(
val rt_cd: String = "", val rt_cd: String = "",
val msg1: String = "", val msg1: String = "",
val ctx_area_fk100: String = "", val ctx_area_fk100: String = "",
val ctx_area_nk100: String = "", val ctx_area_nk100: String = "",
val output1: List<StockHolding> = emptyList(), val output1: List<StockHolding> = emptyList(), // 종목별 잔고
val output2: List<BalanceSummary> = emptyList() val output2: List<BalanceSummary> = emptyList() // 계좌 요약
) )
@Serializable @Serializable
data class StockHolding( data class StockHolding(
val pdno: String = "", // 상품번호 val pdno: String = "", // 상품번호 (종목코드)
val prdt_name: String = "", // 상품명 val prdt_name: String = "", // 상품명
val hldg_qty: String = "0", // 보유수량 val hldg_qty: String = "0", // 보유수량
val pchs_avg_pric: String = "0", // 매입평균가 val pchs_avg_pric: String = "0", // 매입평균가 (수익 계산의 기준점)
val pchs_amt: String = "0", // 매입금액합계 (투자 원금)
val prpr: String = "0", // 현재가 val prpr: String = "0", // 현재가
val evlu_amt: String = "0", // 평가금액
val evlu_pfls_amt: String = "0", // 평가손익금액 (평가금액 - 매입금액)
val evlu_pfls_rt: String = "0.0", // 평가손익률 val evlu_pfls_rt: String = "0.0", // 평가손익률
val evlu_amt: String = "0" , // 평가금액 val fltt_rt: String = "0.0", // 등락율 (당일 시장 강도 분석용)
val ord_psbl_qty : String = "0", val bfdy_cprs_icdc: String = "0", // 전일대비증감 (수급 확인용)
val thdt_buyqty : String = "0", val ord_psbl_qty: String = "0", // 주문가능수량
val thdt_buyqty: String = "0" // 금일매수수량
) )
@Serializable @Serializable
data class BalanceSummary( data class BalanceSummary(
val tot_evlu_amt: String = "0", // 총 평가금액 val dnca_tot_amt: String = "0", // 예수금총금액
val evlu_pfls_rt: String = "0.0", // 총 수익률 (에러 발생 지점: 기본값 추가로 해결) val tot_evlu_amt: String = "0", // 총평가금액 (자산 총계)
val asst_icrt: String = "0.0", // 일부 환경에서 수익률 필드명 val pchs_amt_smtl_amt: String = "0", // 매입금액합계금액
val nass_amt: String = "0" , // 순자산 금액 val evlu_pfls_smtl_amt: String = "0", // 평가손익합계금액
val dnca_tot_amt: String = "0" val asst_icdc_amt: String = "0", // 자산증감액 (어제 대비 성적 - 리포트 핵심)
val thdt_tlex_amt: String = "0" // 금일제비용금액 (세금/수수료 - 순수익 계산용)
) )
@Serializable @Serializable
data class RankingResponse( data class RankingResponse(
var rt_cd : String, var rt_cd : String,
@ -140,36 +145,39 @@ data class OverseasRankingStock(
prdy_ctrt = rate prdy_ctrt = rate
) )
} }
@Serializable @Serializable
data class UnifiedStockHolding( data class UnifiedStockHolding(
val code: String, // 종목코드 val code: String, // 종목코드
val name: String, // 종목명 val name: String, // 종목명
val quantity: String, // 보유수량 val quantity: String, // 보유수량
val avgPrice: String, // 매입단가 val avgPrice: String, // 매입단가 (pchs_avg_pric)
val currentPrice: String, // 현재가 val currentPrice: String, // 현재가 (prpr)
val profitRate: String, // 수익률 val profitRate: String, // 수익률 (evlu_pfls_rt)
val evalAmount: String, // 평가금액 val evalAmount: String, // 평가금액 (evlu_amt)
val isDomestic: Boolean, // 국내/해외 구분 val valuationProfitAmount: String, // 평가손익금액 (evlu_pfls_amt)
val availOrderCount : String, val isDomestic: Boolean, // 국내/해외 구분
val thdtBuyQty: String, val availOrderCount: String, // 주문가능수량
val thdtBuyQty: String, // 금일매수수량
){ // 추가 추천 필드
// 당일 매수 여부 판별 (금일 매수 수량이 0보다 크면 당일 진입 종목) val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt)
val isTodayEntry: Boolean get() = thdtBuyQty.toInt() > 0 val pchsAmount: String = "0" // 총 매입금액 (pchs_amt)
) {
val isTodayEntry: Boolean get() = thdtBuyQty.toIntOrNull() ?: 0 > 0
} }
@Serializable @Serializable
data class UnifiedBalance( data class UnifiedBalance(
val totalAsset: String, // 총 평가자산 val totalAsset: String, // 총 평가자산
val totalProfitRate: String, // 총 수익률 val deposit: String, // 예수금
val deposit: String, val dailyAssetChange: String, // 당일 자산 증감
private val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트 val todayFees: String, // 당일 제비용
val totalProfitRate: String, // 직접 계산된 총 수익률
private val holdings: List<UnifiedStockHolding>
) { ) {
fun getHolldings() = holdings.filter { it.quantity.toInt() > 0 } fun getHoldings() = holdings.filter { (it.quantity.toIntOrNull() ?: 0) > 0 }
} }
@Serializable @Serializable
data class UnfilledOrder( data class UnfilledOrder(
val orgn_odno: String, val orgn_odno: String,

View File

@ -83,47 +83,75 @@ object KisTradeService {
// 국내와 해외 잔고를 비동기로 동시 호출 // 국내와 해외 잔고를 비동기로 동시 호출
val domesticJob = async { fetchDomesticRawBalance(marketCode) } val domesticJob = async { fetchDomesticRawBalance(marketCode) }
val overseasJob = async { fetchOverseasRawBalance() } val overseasJob = async { fetchOverseasRawBalance() }
try { try {
val domRes = domesticJob.await().getOrNull() val domRes = domesticJob.await().getOrNull()
val ovsRes = overseasJob.await().getOrNull() val ovsRes = overseasJob.await().getOrNull()
val combinedHoldings = mutableListOf<UnifiedStockHolding>() val combinedHoldings = mutableListOf<UnifiedStockHolding>()
// 국내 종목 매핑 // 1. 국내 종목 매핑 (신규 추가된 모델 파라미터 반영)
domRes?.output1?.forEach { domRes?.output1?.forEach {
combinedHoldings.add(UnifiedStockHolding( combinedHoldings.add(UnifiedStockHolding(
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty, code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr, avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = true, profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = true,
availOrderCount = it.ord_psbl_qty, thdtBuyQty = it.thdt_buyqty availOrderCount = it.ord_psbl_qty, thdtBuyQty = it.thdt_buyqty,
valuationProfitAmount = it.evlu_pfls_amt,
// 💡 [추가] 신규 파라미터 매핑
dailyChangeRate = it.fltt_rt,
pchsAmount = it.pchs_amt
).apply { ).apply {
if (it.hldg_qty.toLong() > 0) { if (it.hldg_qty.toLong() > 0) {
// println("보유 종목 : ${it.prdt_name} , 수량 : ${it.hldg_qty}") // println("보유 종목 : ${it.prdt_name} , 수량 : ${it.hldg_qty}")
} }
}) })
} }
// 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑) // 2. 해외 종목 매핑 (신규 추가된 모델 파라미터 반영)
// 주의: 해외 API 응답(ovsRes)에 fltt_rt, pchs_amt와 정확히 일치하는 필드가 없다면
// 해당하는 필드로 매핑하거나, 없을 경우 기본값("0.0", "0")을 넣어주어야 에러가 안 납니다.
ovsRes?.output1?.forEach { ovsRes?.output1?.forEach {
combinedHoldings.add(UnifiedStockHolding( combinedHoldings.add(UnifiedStockHolding(
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty, code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr, avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = false, profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = false,
availOrderCount = it.ord_psbl_qty, thdtBuyQty = it.thdt_buyqty availOrderCount = it.ord_psbl_qty, thdtBuyQty = it.thdt_buyqty,
valuationProfitAmount = it.evlu_pfls_amt,
// 💡 [추가] 해외 필드에도 매핑 (해외 API 명세에 맞춰 필드명 조정 필요할 수 있음)
dailyChangeRate = it.fltt_rt ?: "0.0",
pchsAmount = it.pchs_amt ?: "0"
)) ))
} }
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) + // 4. 최종 통합 잔고 반환
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) val domSummary = domRes?.output2?.firstOrNull()
val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L val ovsSummary = ovsRes?.output2?.firstOrNull()
// 1. 자산 및 금액 합산
val totalPchsAmt = (domSummary?.pchs_amt_smtl_amt?.toLongOrNull() ?: 0L) +
(ovsSummary?.pchs_amt_smtl_amt?.toLongOrNull() ?: 0L)
val totalPflsAmt = (domSummary?.evlu_pfls_smtl_amt?.toLongOrNull() ?: 0L) +
(ovsSummary?.evlu_pfls_smtl_amt?.toLongOrNull() ?: 0L)
// 2. 계좌 전체 수익률 계산: (평가손익합계 / 매입금액합계) * 100
val calculatedTotalRate = if (totalPchsAmt > 0) {
(totalPflsAmt.toDouble() / totalPchsAmt.toDouble()) * 100
} else 0.0
// 3. 모델 생성
Result.success(UnifiedBalance( Result.success(UnifiedBalance(
totalAsset = String.format("%,d", totalAmt), totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)),
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0", deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L),
deposit = String.format("%,d", depositAmt), dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)),
todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)),
totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달
holdings = combinedHoldings holdings = combinedHoldings
)) ))
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) } Result.failure(e)
}
} }
/** /**

View File

@ -1,166 +1,477 @@
package report import kotlinx.coroutines.*
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.Duration
object LocalReportGenerator { object LocalReportGenerator {
// 💡 모든 거래 내역의 상세 지표를 담는 DTO private val reportScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
data class TradeDetailData(
val isBuy: Boolean, // 매수/매도 구분 // --- [Raw 데이터 모델 유지] ---
val stockName: String, data class RawSummaryData(
val orderTime: String, // 주문 시각 val type: String,
val timeTaken: String, // 처리 완료 총 시간 또는 미완료 상태 val startAsset: Long,
val execQty: Int, // 처리량 (체결 수량) val endAsset: Long,
val avgPrice: Long, // 체결 평균 단가 val dailyAssetChange: Long,
val profitRate: Double, // 수익률 (매도 시에만 유효) val todayFees: Long,
val profitAmount: Long, // 수익금액 (매도 시에만 유효) val totalProfitRate: Double
val reason: String,
val investmentGrade: String?,
val aiScore: Double?
) )
fun generateAndOpen(startAsset: Long, endAsset: Long, tradeLogs: List<TradeDetailData>) { data class RawHoldingData(
val today = LocalDate.now().toString() val stockName: String,
val profitAmount = endAsset - startAsset val quantity: Int,
val profitRate = if (startAsset > 0) (profitAmount.toDouble() / startAsset) * 100 else 0.0 val avgPrice: Double,
val currentPrice: Double,
val evalAmount: Long,
// 💡 금일 OHLC 정보 추가
val openPrice: Double = 0.0,
val highPrice: Double = 0.0,
val lowPrice: Double = 0.0,
val closePrice: Double = 0.0
)
val profitColor = if (profitAmount > 0) "#FF3B30" else if (profitAmount < 0) "#007AFF" else "#333333"
val profitSign = if (profitAmount > 0) "+" else ""
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
// 1. 매매 상세 내역 HTML 행 생성 data class RawExecutionData(
val tradeRowsHtml = if (tradeLogs.isEmpty()) { val price: Double,
"<tr><td colspan='8' style='padding: 30px; text-align: center; color: #999;'>금일 발생한 주문 내역이 없습니다.</td></tr>" val quantity: Int,
} else { val execTime: String
tradeLogs.joinToString("\n") { trade -> )
// 매수(빨강), 매도(파랑) 뱃지
val typeBadge = if (trade.isBuy) "<span class='badge type-buy'>매수</span>" else "<span class='badge type-sell'>매도</span>"
// 시간 포맷팅 data class RawTradeData(
val orderTimeParsed = LocalDateTime.parse(trade.orderTime).format(timeFormatter) val stockCode: String,
val stockName: String,
val isBuy: Boolean,
val status: String,
val orderTime: String,
val executions: List<RawExecutionData>,
val currentPrice: Double,
val resolvedBuyPrice: Double,
val investmentGrade: String,
val reason: String,
val aiScore: Double,
// 💡 금일 OHLC 정보 추가
val openPrice: Double = 0.0,
val highPrice: Double = 0.0,
val lowPrice: Double = 0.0,
val closePrice: Double = 0.0
)
// 수익률/수익금 처리 (매수일 경우 표시 안 함) // 💡 내부 대시보드 통계용 데이터 클래스
val rateText = if (trade.isBuy) "-" else "${String.format("%.2f", trade.profitRate)}%" private data class DashboardStats(
val rateColor = if (trade.isBuy || trade.profitRate == 0.0) "#333" else if (trade.profitRate > 0) "#FF3B30" else "#007AFF" val buyOrderCount: Int, // 금일 총 매수 주문 횟수
val amountText = if (trade.isBuy) "-" else "${String.format("%,d", trade.profitAmount)}" val sellOrderCount: Int, // 금일 총 매도 주문 횟수
val completedCycles: Int, // 진입 후 청산 완료 건수
val pendingCycles: Int, // 진입 후 미완료(보유) 건수
val totalRealizedProfit: Long, // 총 실현 수익금
val avgRealizedProfit: Long, // 거래당 평균 수익금
val avgRealizedRate: Double, // 거래당 평균 수익률
val winRate: Double, // 승률
val bestTradeName: String,
val bestTradeProfit: Long
)
// 상태/시간 표시 처리 var lastSavedTime = -1L
val timeStatusHtml = if (trade.timeTaken.contains("미완료")) { val reportOpenTime = 60 * 1000 * 60
"<span style='color: #FF9500; font-weight: bold;'>${trade.timeTaken}</span>" fun generateAndOpenAsync(
} else { summary: RawSummaryData,
"<span style='color: #888;'>${trade.timeTaken}</span>" rawHoldings: List<RawHoldingData>,
rawTrades: List<RawTradeData>
) {
reportScope.launch {
try {
// 1. [핵심] 대시보드 통계 지표 추출 (Generator가 직접 계산)
val stats = calculateDashboardStats(rawHoldings, rawTrades)
// 2. 탭 2 & 3 HTML 가공
val holdingsHtml = processHoldings(rawHoldings)
val tradesHtml = processTrades(rawTrades)
// 3. 전체 HTML 조립
val htmlContent = buildHtml(summary, stats, holdingsHtml, tradesHtml)
var currentTime = System.currentTimeMillis()
if (summary.type.equals("END", true) || currentTime >= (lastSavedTime + reportOpenTime)) {
saveAndOpen(summary.type, htmlContent)
lastSavedTime = currentTime
} }
} catch (e: Exception) {
println("❌ [Report] 리포트 비동기 생성 중 오류 발생: ${e.message}")
e.printStackTrace()
}
}
}
val gradeHtml = trade.investmentGrade?.let { "<span class='badge grade-$it'>$it</span>" } ?: "" // --- [새로운 통계 계산 로직] ---
private fun calculateDashboardStats(holdings: List<RawHoldingData>, trades: List<RawTradeData>): DashboardStats {
val tradesByStock = trades.groupBy { it.stockCode }
""" var buyCount = 0
<tr> var sellCount = 0
<td class="center">$typeBadge</td> var completed = 0
<td class="stock-name"><strong>${trade.stockName}</strong> $gradeHtml</td> var pending = 0
<td class="center">$orderTimeParsed</td> var totalProfit = 0L
<td class="center">$timeStatusHtml</td> var totalRateSum = 0.0
<td class="right">${String.format("%,d", trade.execQty)}</td> var closedTradeCount = 0
<td class="right">${String.format("%,d", trade.avgPrice)}</td>
<td class="right rate" style="color: $rateColor;">$rateText</td> var bestName = "-"
<td class="right rate" style="color: $rateColor;">$amountText</td> var bestProfit = Long.MIN_VALUE
</tr>
<tr class="reason-row"> tradesByStock.forEach { (_, stockTrades) ->
<td colspan="8"> val buys = stockTrades.filter { it.isBuy }
<div class="reason-box"> val sells = stockTrades.filter { !it.isBuy }
<span class="reason-icon">💡</span>
<span class="reason-text"><strong>AI (${String.format("%.1f", trade.aiScore ?: 0.0)}):</strong> ${trade.reason.replace("\n", " ")}</span> buyCount += buys.size
</div> sellCount += sells.size
</td>
</tr> // 1. 청산 완료 여부 판별
""".trimIndent() if (buys.isNotEmpty() && sells.isNotEmpty()) {
completed++
val avgBuy = calculateAvgPrice(buys)
val avgSell = calculateAvgPrice(sells)
val totalQty = sells.flatMap { it.executions }.sumOf { it.quantity }
val profit = ((avgSell - avgBuy) * totalQty).toLong()
val rate = if (avgBuy > 0) ((avgSell - avgBuy) / avgBuy) * 100 else 0.0
totalProfit += profit
totalRateSum += rate
closedTradeCount++
if (profit > bestProfit) { bestProfit = profit; bestName = stockTrades.first().stockName }
}
else if (buys.isNotEmpty() && sells.isEmpty()) {
pending++ // 진입은 했으나 아직 안 판 종목
}
else if (sells.isNotEmpty() && buys.isEmpty()) {
// 스윙 종목 매도 처리
val avgSell = calculateAvgPrice(sells)
val totalQty = sells.flatMap { it.executions }.sumOf { it.quantity }
val buyPrice = sells.first().resolvedBuyPrice
if (buyPrice > 0) {
val profit = ((avgSell - buyPrice) * totalQty).toLong()
val rate = ((avgSell - buyPrice) / buyPrice) * 100
totalProfit += profit
totalRateSum += rate
closedTradeCount++
if (profit > bestProfit) { bestProfit = profit; bestName = stockTrades.first().stockName }
}
} }
} }
// 2. 전체 HTML 템플릿 return DashboardStats(
val htmlTemplate = """ buyOrderCount = buyCount,
<!DOCTYPE html> sellOrderCount = sellCount,
<html lang="ko"> completedCycles = completed,
<head> pendingCycles = pending,
<meta charset="UTF-8"> totalRealizedProfit = totalProfit,
<title>ATRADE 일간 리포트 - $today</title> avgRealizedProfit = if (closedTradeCount > 0) totalProfit / closedTradeCount else 0L,
<style> avgRealizedRate = if (closedTradeCount > 0) totalRateSum / closedTradeCount else 0.0,
:root { --red: #FF3B30; --blue: #007AFF; --bg: #f4f7f6; --text: #333; } winRate = if (closedTradeCount > 0) (tradesByStock.filter { /* 승리판별로직 */ true }.size.toDouble()) /* 실제 승률 계산 필요 시 추가 */ else 0.0,
body { font-family: 'Pretendard', 'Apple SD Gothic Neo', sans-serif; background-color: var(--bg); color: var(--text); padding: 40px 20px; margin: 0; } bestTradeName = bestName,
.container { max-width: 1000px; margin: 0 auto; background-color: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.05); } bestTradeProfit = if (bestProfit == Long.MIN_VALUE) 0L else bestProfit
.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 40px; text-align: center; } )
.header h1 { margin: 0; font-size: 32px; font-weight: 800; } }
.summary { padding: 40px; text-align: center; border-bottom: 1px solid #eee; }
.summary .profit { font-size: 54px; font-weight: 800; color: $profitColor; margin: 10px 0; }
.details { display: flex; justify-content: center; gap: 50px; margin-top: 25px; background: #f8f9fa; padding: 20px; border-radius: 12px; }
.details div { display: flex; flex-direction: column; }
.details span.label { font-size: 13px; color: #888; margin-bottom: 5px; }
.details span.value { font-size: 20px; font-weight: 700; color: #333; }
.table-container { padding: 40px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 800px; }
th { padding: 12px; text-align: left; background-color: #f8f9fa; color: #555; border-bottom: 2px solid #ddd; }
th.right { text-align: right; }
th.center { text-align: center; }
td { padding: 14px 12px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
td.right { text-align: right; font-weight: 500; }
td.center { text-align: center; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; color: #fff; margin-left: 4px; }
.type-buy { background-color: var(--red); }
.type-sell { background-color: var(--blue); }
.grade-S { background-color: #8A2BE2; } .grade-A { background-color: #FF9500; } .grade-B { background-color: #34C759; }
.reason-row td { padding: 0 12px 12px 12px; border-bottom: 2px solid #eee; }
.reason-box { background-color: #f8fcfd; border-left: 4px solid #34b3e4; padding: 10px 14px; border-radius: 0 6px 6px 0; font-size: 13px; color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ATRADE 일간 매매 상세 리포트</h1>
</div>
<div class="summary">
<h2>오늘의 실현 손익 (END - START)</h2>
<p class="profit">$profitSign${String.format("%,d", profitAmount)} <span style="font-size: 32px;">($profitSign${String.format("%.2f", profitRate)}%)</span></p>
</div>
<div class="table-container">
<h3>📊 금일 주문 체결 내역 전체</h3>
<table>
<thead>
<tr>
<th class="center">구분</th>
<th>종목명</th>
<th class="center">주문시각</th>
<th class="center">소요시간/상태</th>
<th class="right">체결량</th>
<th class="right">평균단가</th>
<th class="right">수익률</th>
<th class="right">수익금</th>
</tr>
</thead>
<tbody>
$tradeRowsHtml
</tbody>
</table>
</div>
</div>
</body>
</html>
""".trimIndent()
val directory = File("reports") // --- [탭 2, 3 처리 로직] (이전과 거의 동일) ---
if (!directory.exists()) directory.mkdirs() private fun processHoldings(rawHoldings: List<RawHoldingData>): String {
val reportFile = File(directory, "ATRADE_Report_$today.html") if (rawHoldings.isEmpty()) return "<tr><td colspan='8'>현재 보유 중인 종목이 없습니다.</td></tr>"
try { return rawHoldings.joinToString("\n") { h ->
reportFile.writeText(htmlTemplate, Charsets.UTF_8) val valuationProfit = ((h.currentPrice - h.avgPrice) * h.quantity).toLong()
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { val profitRate = if (h.avgPrice > 0) ((h.currentPrice - h.avgPrice) / h.avgPrice) * 100 else 0.0
Desktop.getDesktop().browse(reportFile.toURI()) val profitClass = if (valuationProfit > 0) "plus" else if (valuationProfit < 0) "minus" else ""
// 💡 4개 값이 모두 0보다 클 때만 HTML 문자열 생성
val ohlcHtml = if (h.openPrice > 0 && h.highPrice > 0 && h.lowPrice > 0 && h.currentPrice > 0) {
"""
<span style='font-size:0.85em; color:#7f8c8d;'>
: ${String.format("%,d", h.openPrice.toLong())}<br>
: ${String.format("%,d", h.highPrice.toLong())}<br>
: ${String.format("%,d", h.lowPrice.toLong())}<br>
: ${String.format("%,d", h.currentPrice.toLong())}
</span>
""".trimIndent()
} else {
"-" // 시세 데이터가 없을 경우 표시할 텍스트 (또는 "")
} }
} catch (e: Exception) {
e.printStackTrace() """
<tr>
<td><strong>${h.stockName}</strong></td>
<td>${h.quantity}</td>
<td>${String.format("%,d", h.avgPrice.toLong())}</td>
<td>${String.format("%,d", h.currentPrice.toLong())}</td>
<td>${String.format("%,d", h.evalAmount)}</td>
<td class="$profitClass">${String.format("%.2f%%", profitRate)}</td>
<td class="$profitClass">${String.format("%,d", valuationProfit)}</td>
<td>$ohlcHtml</td>
</tr>
"""
}
}
private fun processTrades(rawTrades: List<RawTradeData>): String {
if (rawTrades.isEmpty()) return "<tr><td colspan='8'>당일 거래 내역이 없습니다.</td></tr>"
val htmlBuilder = StringBuilder()
val tradesByStock = rawTrades.groupBy { it.stockCode }
for ((_, trades) in tradesByStock) {
val buyTrades = trades.filter { it.isBuy }
val sellTrades = trades.filter { !it.isBuy }
val stockName = trades.first().stockName
val reason = trades.first().reason
val aiScore = trades.first().aiScore
val investmentGrade = trades.first().investmentGrade
val h = trades.first()
val ohlcHtml = if (h.openPrice > 0 && h.highPrice > 0 && h.lowPrice > 0 && h.currentPrice > 0) {
"""
<span style='font-size:0.85em; color:#7f8c8d;'>
: ${String.format("%,d", h.openPrice.toLong())}<br>
: ${String.format("%,d", h.highPrice.toLong())}<br>
: ${String.format("%,d", h.lowPrice.toLong())}<br>
: ${String.format("%,d", h.currentPrice.toLong())}
</span>
""".trimIndent()
} else {
"-" // 시세 데이터가 없을 경우 표시할 텍스트 (또는 "")
}
if (buyTrades.isNotEmpty() && sellTrades.isNotEmpty()) {
val totalSellQty = sellTrades.flatMap { it.executions }.sumOf { it.quantity }
val avgBuyPrice = calculateAvgPrice(buyTrades)
val avgSellPrice = calculateAvgPrice(sellTrades)
val profitAmount = ((avgSellPrice - avgBuyPrice) * totalSellQty).toLong()
val profitRate = if (avgBuyPrice > 0) ((avgSellPrice - avgBuyPrice) / avgBuyPrice) * 100 else 0.0
val profitClass = if (profitAmount > 0) "plus" else if (profitAmount < 0) "minus" else ""
val buyTime = buyTrades.first().orderTime.substringAfter("T").substringBefore(".")
val sellTime = sellTrades.last().orderTime.substringAfter("T").substringBefore(".")
htmlBuilder.append("""
<tr style="background-color: #f0f8ff;">
<td><span style="color:#8e44ad; font-weight:bold;">당일 청산</span></td>
<td>$stockName <br><span style="font-size:0.8em; color:#7f8c8d;">$investmentGrade</span></td>
<td>진입: $buyTime<br>청산: $sellTime</td>
<td>-</td>
<td>${totalSellQty}</td>
<td>매수: ${String.format("%,d", avgBuyPrice.toLong())}<br>매도: ${String.format("%,d", avgSellPrice.toLong())}</td>
<td class="$profitClass">${String.format("%.2f%%", profitRate)}</td>
<td class="$profitClass">실현: ${String.format("%,d", profitAmount)}</td>
<td >$ohlcHtml</td>
</tr>
<tr class="reason"><td colspan="8">💡 <strong>AI (${String.format("%.1f", aiScore)}):</strong> $reason</td></tr>
""")
} else if (sellTrades.isNotEmpty() && buyTrades.isEmpty()) {
sellTrades.forEach { sell ->
val sellQty = sell.executions.sumOf { it.quantity }
val avgSellPrice = calculateAvgPrice(listOf(sell))
val buyPrice = sell.resolvedBuyPrice
val profitAmount = if (buyPrice > 0) ((avgSellPrice - buyPrice) * sellQty).toLong() else 0L
val profitRate = if (buyPrice > 0) ((avgSellPrice - buyPrice) / buyPrice) * 100 else 0.0
val profitClass = if (profitAmount > 0) "plus" else if (profitAmount < 0) "minus" else ""
htmlBuilder.append("""
<tr>
<td><span class='sell'>스윙 청산</span></td>
<td>$stockName</td>
<td>${sell.orderTime.substringAfter("T").substringBefore(".")}</td>
<td>-</td>
<td>${sellQty}</td>
<td>매도단가: ${String.format("%,d", avgSellPrice.toLong())}</td>
<td class="$profitClass">${String.format("%.2f%%", profitRate)}</td>
<td class="$profitClass">실현: ${String.format("%,d", profitAmount)}</td>
<td >$ohlcHtml</td>
</tr>
""")
}
} else if (buyTrades.isNotEmpty() && sellTrades.isEmpty()) {
buyTrades.forEach { buy ->
val buyQty = buy.executions.sumOf { it.quantity }
val avgBuyPrice = calculateAvgPrice(listOf(buy))
val currentPrice = buy.currentPrice
val valuationAmount = if (avgBuyPrice > 0) ((currentPrice - avgBuyPrice) * buyQty).toLong() else 0L
val profitClass = if (valuationAmount > 0) "plus" else if (valuationAmount < 0) "minus" else ""
htmlBuilder.append("""
<tr>
<td><span class='buy'>신규 진입</span></td>
<td>$stockName <br><span style="font-size:0.8em; color:#7f8c8d;">$investmentGrade</span></td>
<td>${buy.orderTime.substringAfter("T").substringBefore(".")}</td>
<td>-</td>
<td>${buyQty}</td>
<td>매수단가: ${String.format("%,d", avgBuyPrice.toLong())}</td>
<td class="$profitClass">-</td>
<td class="$profitClass">평가: ${String.format("%,d", valuationAmount)}</td>
<td >$ohlcHtml</td>
</tr>
<tr class="reason"><td colspan="8">💡 <strong>AI (${String.format("%.1f", aiScore)}):</strong> $reason</td></tr>
""")
}
}
}
return htmlBuilder.toString()
}
private fun calculateAvgPrice(trades: List<RawTradeData>): Double {
val allExecutions = trades.flatMap { it.executions }
val totalQty = allExecutions.sumOf { it.quantity }
return if (totalQty > 0) allExecutions.sumOf { it.price * it.quantity } / totalQty else trades.firstOrNull()?.currentPrice ?: 0.0
}
// --- [HTML 빌더] ---
private fun buildHtml(summary: RawSummaryData, stats: DashboardStats, holdingsHtml: String, tradesHtml: String): String {
val now = LocalDateTime.now()
val dateStr = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val timeStr = now.format(DateTimeFormatter.ofPattern("HHmmss"))
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ATRADE 데일리 리포트 (${summary.type})</title>
<style>
body { font-family: 'Pretendard', 'Malgun Gothic', sans-serif; background: #f0f2f5; color: #1a1a1a; margin: 20px; }
h1 { color: #1e293b; border-bottom: 3px solid #3b82f6; padding-bottom: 10px; font-size: 24px; }
/* 탭 UI */
.tab { overflow: hidden; background-color: #ffffff; border-radius: 12px 12px 0 0; display: flex; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.tab button { background-color: inherit; border: none; outline: none; cursor: pointer; padding: 16px 24px; transition: 0.2s; font-size: 15px; font-weight: 600; color: #64748b; flex: 1; border-bottom: 3px solid transparent; }
.tab button:hover { background-color: #f8fafc; color: #3b82f6; }
.tab button.active { color: #3b82f6; border-bottom: 3px solid #3b82f6; background-color: #eff6ff; }
.tabcontent { display: none; padding: 25px; background: #fff; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
/* 요약 대시보드 카드 */
.dashboard-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 20px; background: #1e293b; color: white; border-radius: 12px; }
.dashboard-header .main-asset { font-size: 32px; font-weight: 800; }
.dashboard-header .asset-change { font-size: 18px; font-weight: 600; padding: 4px 12px; border-radius: 20px; }
.bg-plus { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
.bg-minus { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.grid-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-bottom: 16px; }
.stat-card { background: #f8fafc; padding: 20px; border-radius: 12px; border: 1px solid #e2e8f0; }
.stat-card h3 { margin: 0 0 8px 0; font-size: 13px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card p { margin: 0; font-size: 22px; font-weight: 700; color: #0f172a; }
.highlight-plus { color: #ef4444; } /* 주식은 빨간색이 상승/수익 */
.highlight-minus { color: #3b82f6; } /* 주식은 파란색이 하락/손실 */
table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 14px; }
th, td { padding: 14px 12px; border-bottom: 1px solid #e2e8f0; text-align: center; }
th { background: #f8fafc; color: #475569; font-weight: 600; position: sticky; top: 0; }
tr:hover { background-color: #f1f5f9; }
.buy { color: #ef4444; font-weight: bold; }
.sell { color: #3b82f6; font-weight: bold; }
.plus { color: #ef4444; font-weight: bold; }
.minus { color: #3b82f6; font-weight: bold; }
.reason { text-align: left; font-size: 13px; color: #475569; background: #fafaf9; }
</style>
</head>
<body>
<h1>📊 ATRADE 일일 운용 리포트 (${summary.type} : $dateStr $timeStr)</h1>
<div class="tab">
<button class="tablinks" onclick="openTab(event, 'TabSummary')" id="defaultOpen">📌 당일 요약 대시보드</button>
<button class="tablinks" onclick="openTab(event, 'TabHoldings')">💼 현재 보유 잔고</button>
<button class="tablinks" onclick="openTab(event, 'TabTrades')">📝 거래 내역 상세</button>
</div>
<div id="TabSummary" class="tabcontent">
<div class="dashboard-header">
<div>
<div style="font-size: 14px; color: #94a3b8;">최종 운용 자산</div>
<div class="main-asset">${String.format("%,d", summary.endAsset)} </div>
</div>
<div class="asset-change ${if (summary.dailyAssetChange >= 0) "bg-plus" else "bg-minus"}">
오늘 수익: ${if (summary.dailyAssetChange > 0) "+" else ""}${String.format("%,d", summary.dailyAssetChange)}
</div>
</div>
<div class="grid-container">
<div class="stat-card">
<h3>🔄 금일 주문 활동</h3>
<p>매수 ${stats.buyOrderCount} / 매도 ${stats.sellOrderCount}</p>
</div>
<div class="stat-card">
<h3>🎯 청산 상태 (진입 기준)</h3>
<p><span style="color:#22c55e;">완료 ${stats.completedCycles}</span> / <span style="color:#f59e0b;">미완료 ${stats.pendingCycles}</span></p>
</div>
<div class="stat-card">
<h3>💸 거래당 평균 수익</h3>
<p class="${if (stats.avgRealizedProfit >= 0) "plus" else "minus"}">
${String.format("%,d", stats.avgRealizedProfit)} (${String.format("%.2f", stats.avgRealizedRate)}%)
</p>
</div>
</div>
<div class="grid-container">
<div class="stat-card" style="border-left: 4px solid #ef4444;">
<h3>💰 실현 손익 (확정)</h3>
<p class="${if (stats.totalRealizedProfit >= 0) "plus" else "minus"}">
${if (stats.totalRealizedProfit > 0) "+" else ""}${String.format("%,d", stats.totalRealizedProfit)}
</p>
</div>
<div class="stat-card">
<h3>🏆 오늘의 BEST</h3>
<p>${stats.bestTradeName} <span style="font-size:15px; color:#ef4444;">(+${String.format("%,d", stats.bestTradeProfit)})</span></p>
</div>
<div class="stat-card">
<h3>🏦 발생 제비용</h3>
<p>${String.format("%,d", summary.todayFees)} </p>
</div>
</div>
</div>
<div id="TabHoldings" class="tabcontent">
<table>
<thead><tr><th>종목명</th><th>보유수량</th><th>매입단가</th><th>현재가</th><th>평가금액</th><th>수익률</th><th>평가손익</th></tr><th>OHLC</th></thead>
<tbody>$holdingsHtml</tbody>
</table>
</div>
<div id="TabTrades" class="tabcontent">
<table>
<thead><tr><th>구분</th><th>종목명</th><th>체결시각</th><th>소요시간/상태</th><th>체결량</th><th>체결단가</th><th>수익률</th><th>수익금(실현/평가)</th><th>OHLC</th></tr></thead>
<tbody>$tradesHtml</tbody>
</table>
</div>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) tabcontent[i].style.display = "none";
tablinks = document.getElementsByClassName("tablinks");
for (i = 0; i < tablinks.length; i++) tablinks[i].className = tablinks[i].className.replace(" active", "");
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
document.getElementById("defaultOpen").click();
</script>
</body>
</html>
""".trimIndent()
}
private fun saveAndOpen(type: String, htmlContent: String) {
val now = LocalDateTime.now()
val year = now.year.toString()
val month = String.format("%02d", now.monthValue)
val dateStr = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val timeStr = now.format(DateTimeFormatter.ofPattern("HHmmss"))
val directory = File("reports/$year/$month")
if (!directory.exists()) directory.mkdirs()
val fileName = "ATRADE_REPORT_${dateStr}_${timeStr}_${type}.html"
val reportFile = File(directory, fileName)
reportFile.writeText(htmlContent, Charsets.UTF_8)
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(reportFile.toURI())
} }
} }
} }

View File

@ -1,6 +1,13 @@
package report package report
import io.ktor.utils.io.core.String 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.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -8,11 +15,12 @@ import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import report.database.* import report.database.*
import model.* import model.*
import network.KisTradeService
import service.InvestmentGrade import service.InvestmentGrade
import java.time.Duration import java.time.Duration
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import org.jetbrains.exposed.dao.id.IntIdTable
enum class SnapshotType { START, END, MIDDLE } enum class SnapshotType { START, END, MIDDLE }
@ -50,6 +58,182 @@ object TradingReportManager : TradingReportService {
// Key: 종목코드, Value: 현재 활성화된 Position ID // Key: 종목코드, Value: 현재 활성화된 Position ID
private val activePositions = mutableMapOf<String, String>() 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() { override fun recordConfigChange() {
transaction(DatabaseFactory.reportDb) { transaction(DatabaseFactory.reportDb) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
@ -108,98 +292,72 @@ object TradingReportManager : TradingReportService {
} }
} }
/**
* [1] 자산 현황 보유 종목 스냅샷 저장
* isClose 파라미터가 true로 들어오면 모든 기록을 마치고 자동으로 일간 리포트를 생성합니다.
*/
override fun recordAssetSnapshot( // ==========================================
type: SnapshotType, // 3. 내부 유틸리티 함수
balance: UnifiedBalance, // ==========================================
remark: String?
) {
transaction(DatabaseFactory.reportDb) {
val todayDate = LocalDate.now().toString()
// 💡 [핵심 로직] 스냅샷 타입 자동 결정 private fun calculateTimeTaken(orderTimeStr: String, lastExecTimeStr: String?, status: String): String {
val actualSnapshotType = if (type == SnapshotType.END) { if (lastExecTimeStr == null) return "미체결 (진행중)"
SnapshotType.END 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 { } else {
// 오늘 날짜로 기록된 START 스냅샷이 있는지 검사 "${duration.toMillis() / 1000.0}"
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()
} }
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, override fun recordTradeDecision(orderNo: String,
stockCode: String, stockCode: String,
stockName: String, stockName: String,
@ -330,126 +488,126 @@ object TradingReportManager : TradingReportService {
* [4] 마감 로컬 리포트(HTML) 자동 생성 * [4] 마감 로컬 리포트(HTML) 자동 생성
*/ */
override fun generateDailyLocalReport() { override fun generateDailyLocalReport() {
transaction(DatabaseFactory.reportDb) { // transaction(DatabaseFactory.reportDb) {
val today = LocalDate.now().toString() // val today = LocalDate.now().toString()
//
val startAsset = AssetSnapshotTable.select { // val startAsset = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name) // (AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: 0L // }.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: 0L
//
val endAsset = AssetSnapshotTable.select { // val endAsset = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.END.name) // (AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.END.name)
}.orderBy(AssetSnapshotTable.id to SortOrder.DESC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: startAsset // }.orderBy(AssetSnapshotTable.id to SortOrder.DESC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: startAsset
//
// 💡 [변경] isBuy == false 필터를 제거하여 금일 발생한 '모든' 주문 내역을 가져옵니다. // // 💡 [변경] isBuy == false 필터를 제거하여 금일 발생한 '모든' 주문 내역을 가져옵니다.
val allTrades = TradeHistoryTable.select { // val allTrades = TradeHistoryTable.select {
(TradeHistoryTable.orderTime like "$today%") // (TradeHistoryTable.orderTime like "$today%")
}.orderBy(TradeHistoryTable.orderTime to SortOrder.DESC).toList() // }.orderBy(TradeHistoryTable.orderTime to SortOrder.DESC).toList()
//
val tradeLogs = allTrades.map { row -> // val tradeLogs = allTrades.map { row ->
val tradeId = row[TradeHistoryTable.id] // val tradeId = row[TradeHistoryTable.id]
val stockCode = row[TradeHistoryTable.stockCode] // val stockCode = row[TradeHistoryTable.stockCode]
val isBuy = row[TradeHistoryTable.isBuy] // val isBuy = row[TradeHistoryTable.isBuy]
val status = row[TradeHistoryTable.status] // val status = row[TradeHistoryTable.status]
val orderTimeStr = row[TradeHistoryTable.orderTime] // 예: "2024-05-20T09:10:00.123" // val orderTimeStr = row[TradeHistoryTable.orderTime] // 예: "2024-05-20T09:10:00.123"
//
// 1. 체결 상세 정보 가져오기 // // 1. 체결 상세 정보 가져오기
val executions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.toList() // val executions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.toList()
val execQty = executions.sumOf { it[ExecutionDetailsTable.quantity] } // val execQty = executions.sumOf { it[ExecutionDetailsTable.quantity] }
//
// VWAP 평균 체결가 산출 // // VWAP 평균 체결가 산출
val avgPrice = if (execQty > 0) { // val avgPrice = if (execQty > 0) {
executions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / execQty // executions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / execQty
} else { // } else {
row[TradeHistoryTable.currentPrice] ?: 0.0 // row[TradeHistoryTable.currentPrice] ?: 0.0
} // }
//
// 💡 2. 주문부터 마지막 체결까지 소요된 시간 계산 로직 // // 💡 2. 주문부터 마지막 체결까지 소요된 시간 계산 로직
val lastExecTimeStr = executions.maxByOrNull { it[ExecutionDetailsTable.execTime] }?.get(ExecutionDetailsTable.execTime) // val lastExecTimeStr = executions.maxByOrNull { it[ExecutionDetailsTable.execTime] }?.get(ExecutionDetailsTable.execTime)
//
val timeTakenStr = if (lastExecTimeStr != null) { // val timeTakenStr = if (lastExecTimeStr != null) {
try { // try {
val orderTime = LocalDateTime.parse(orderTimeStr) // val orderTime = LocalDateTime.parse(orderTimeStr)
val lastExecTime = LocalDateTime.parse(lastExecTimeStr) // val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
val duration = Duration.between(orderTime, lastExecTime) // val duration = Duration.between(orderTime, lastExecTime)
//
val timeString = if (duration.toMinutes() > 0) { // val timeString = if (duration.toMinutes() > 0) {
"${duration.toMinutes()}${duration.seconds % 60}" // "${duration.toMinutes()}분 ${duration.seconds % 60}초"
} else { // } else {
"${duration.toMillis() / 1000.0}" // "${duration.toMillis() / 1000.0}초"
} // }
//
// 아직 상태가 COMPLETED가 아니면 미완료 표시 (현재 상태값이 ORDERED, PARTIAL 등일 경우) // // 아직 상태가 COMPLETED가 아니면 미완료 표시 (현재 상태값이 ORDERED, PARTIAL 등일 경우)
if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)" // if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
} catch (e: Exception) { // } catch (e: Exception) {
"계산 불가" // "계산 불가"
} // }
} else { // } else {
"미체결 (진행중)" // "미체결 (진행중)"
} // }
//
// 3. 수익률 및 수익금 계산 (매도일 때만) // // 3. 수익률 및 수익금 계산 (매도일 때만)
var calculatedProfitRate = 0.0 // var calculatedProfitRate = 0.0
var calculatedProfitAmount = 0L // var calculatedProfitAmount = 0L
//
if (!isBuy) { // if (!isBuy) {
// 과거 매수 단가 찾기 (1순위: 이전 매수 기록, 2순위: 아침 스냅샷) // // 과거 매수 단가 찾기 (1순위: 이전 매수 기록, 2순위: 아침 스냅샷)
val recentBuyTrade = TradeHistoryTable.select { // val recentBuyTrade = TradeHistoryTable.select {
(TradeHistoryTable.stockCode eq stockCode) and // (TradeHistoryTable.stockCode eq stockCode) and
(TradeHistoryTable.isBuy eq true) and // (TradeHistoryTable.isBuy eq true) and
(TradeHistoryTable.id less tradeId) // (TradeHistoryTable.id less tradeId)
}.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull() // }.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
//
var avgBuyPrice = 0.0 // var avgBuyPrice = 0.0
if (recentBuyTrade != null) { // if (recentBuyTrade != null) {
val buyExecutions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id] }.toList() // val buyExecutions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id] }.toList()
val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] } // val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
if (buyQty > 0) avgBuyPrice = buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty // if (buyQty > 0) avgBuyPrice = buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
} // }
//
// 2차 시도: 오늘 아침 START 스냅샷에 기록된 보유 평단가 // // 2차 시도: 오늘 아침 START 스냅샷에 기록된 보유 평단가
if (avgBuyPrice == 0.0) { // if (avgBuyPrice == 0.0) {
val startHoldings = SnapshotHoldingsTable.innerJoin(AssetSnapshotTable).select { // val startHoldings = SnapshotHoldingsTable.innerJoin(AssetSnapshotTable).select {
(SnapshotHoldingsTable.code eq stockCode) and // (SnapshotHoldingsTable.code eq stockCode) and
(AssetSnapshotTable.date eq today) and // (AssetSnapshotTable.date eq today) and
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name) // (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.singleOrNull() // }.singleOrNull()
avgBuyPrice = startHoldings?.get(SnapshotHoldingsTable.avgPrice) ?: 0.0 // avgBuyPrice = startHoldings?.get(SnapshotHoldingsTable.avgPrice) ?: 0.0
} // }
//
// 💡 3차 시도 (궁극의 방어막): 리포팅 도입 이전부터 들고 있던 레거시 종목일 경우 // // 💡 3차 시도 (궁극의 방어막): 리포팅 도입 이전부터 들고 있던 레거시 종목일 경우
// 매도 주문 당시에 DB에 백업해두었던 증권사 보유 평단가를 그대로 사용합니다. // // 매도 주문 당시에 DB에 백업해두었던 증권사 보유 평단가를 그대로 사용합니다.
if (avgBuyPrice == 0.0) { // if (avgBuyPrice == 0.0) {
avgBuyPrice = row[TradeHistoryTable.holdingAvgPrice] // avgBuyPrice = row[TradeHistoryTable.holdingAvgPrice]
} // }
//
// --- [C] 최종 수익률 계산 --- // // --- [C] 최종 수익률 계산 ---
if (avgBuyPrice > 0.0) { // if (avgBuyPrice > 0.0) {
calculatedProfitRate = ((avgPrice - avgBuyPrice) / avgBuyPrice) * 100 // calculatedProfitRate = ((avgPrice - avgBuyPrice) / avgBuyPrice) * 100
calculatedProfitAmount = ((avgPrice - avgBuyPrice) * execQty).toLong() // calculatedProfitAmount = ((avgPrice - avgBuyPrice) * execQty).toLong()
} else { // } else {
calculatedProfitRate = 0.0 // calculatedProfitRate = 0.0
calculatedProfitAmount = 0L // calculatedProfitAmount = 0L
} // }
} // }
//
// 4. DTO 조립 // // 4. DTO 조립
LocalReportGenerator.TradeDetailData( // LocalReportGenerator.TradeDetailData(
isBuy = isBuy, // isBuy = isBuy,
stockName = row[TradeHistoryTable.stockName], // stockName = row[TradeHistoryTable.stockName],
orderTime = orderTimeStr, // orderTime = orderTimeStr,
timeTaken = timeTakenStr, // timeTaken = timeTakenStr,
execQty = execQty, // execQty = execQty,
avgPrice = avgPrice.toLong(), // avgPrice = avgPrice.toLong(),
profitRate = calculatedProfitRate, // profitRate = calculatedProfitRate,
profitAmount = calculatedProfitAmount, // profitAmount = calculatedProfitAmount,
reason = row[TradeHistoryTable.reason], // reason = row[TradeHistoryTable.reason],
investmentGrade = row[TradeHistoryTable.investmentGrade], // investmentGrade = row[TradeHistoryTable.investmentGrade],
aiScore = row[TradeHistoryTable.aiScore] // aiScore = row[TradeHistoryTable.aiScore]
) // )
} // }
//
LocalReportGenerator.generateAndOpen(startAsset, endAsset, tradeLogs) // LocalReportGenerator.generateAndOpen(startAsset, endAsset, tradeLogs)
} // }
} }
} }

View File

@ -1,6 +1,7 @@
package report.database package report.database
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.dao.id.IntIdTable
// 💡 [신규] 설정 변경 이력 전용 테이블 // 💡 [신규] 설정 변경 이력 전용 테이블
object ConfigHistoryTable : Table("config_history") { object ConfigHistoryTable : Table("config_history") {
val id = integer("id").autoIncrement() val id = integer("id").autoIncrement()
@ -11,18 +12,22 @@ object ConfigHistoryTable : Table("config_history") {
} }
// [1] 자산 스냅샷 마스터 (설정 필드 제거) // [1] 자산 스냅샷 마스터 (설정 필드 제거)
object AssetSnapshotTable : Table("asset_snapshots") { object AssetSnapshotTable : IntIdTable("asset_snapshots") {
val id = integer("id").autoIncrement() // override val id = integer("id").autoIncrement()
val date = varchar("date", 10) val date = varchar("date", 10)
val snapshotType = varchar("type", 20) val snapshotType = varchar("type", 20)
val totalAsset = long("total_asset") val totalAsset = long("total_asset")
val totalProfitRate = double("profit_rate") val totalProfitRate = double("profit_rate") // 대표님이 추가하신 기존 필드
val deposit = long("deposit") val deposit = long("deposit") // 대표님이 추가하신 기존 필드
val appliedConfigJson = text("applied_config") // 💡 [신규 추가] 리포트에 표시할 일일 변동 지표 (기존 코드 영향 0%)
val dailyAssetChange = long("daily_asset_change").default(0L)
val todayFees = long("today_fees").default(0L)
val appliedConfigJson = text("applied_config").nullable()
val remark = varchar("remark", 255).nullable() val remark = varchar("remark", 255).nullable()
override val primaryKey = PrimaryKey(id) // override val primaryKey = PrimaryKey(id)
} }
// [2] 보유 종목 상세 (UnifiedStockHolding 기준) // [2] 보유 종목 상세 (UnifiedStockHolding 기준)
@ -38,20 +43,22 @@ object SnapshotHoldingsTable : Table("snapshot_holdings") {
val currentPrice = double("current_price") val currentPrice = double("current_price")
val profitRate = double("profit_rate") val profitRate = double("profit_rate")
val evalAmount = long("eval_amount") val evalAmount = long("eval_amount")
val isDomestic = bool("is_domestic") val isDomestic = bool("is_domestic").nullable()
val isTodayEntry = bool("is_today_entry") // 당일 진입 여부 val isTodayEntry = bool("is_today_entry").nullable() // 당일 진입 여부
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
// [3] 매매 이력 및 결정 근거 (TradingDecision + 수정된 TradeHistoryTable) // [3] 매매 이력 및 결정 근거 (TradingDecision + 수정된 TradeHistoryTable)
object TradeHistoryTable : Table("trade_history") { object TradeHistoryTable : IntIdTable("trade_history") {
val id = integer("id").autoIncrement() // val id = integer("id").autoIncrement()
val orderNo = varchar("order_no", 50).uniqueIndex() val orderNo = varchar("order_no", 50).uniqueIndex()
val stockCode = varchar("stock_code", 20) val stockCode = varchar("stock_code", 20)
val stockName = varchar("stock_name", 100) val stockName = varchar("stock_name", 100)
val orderTime = varchar("order_time", 50) val orderTime = varchar("order_time", 50)
val isBuy = bool("is_buy") val isBuy = bool("is_buy")
// 💡 [핵심 신규 필드] 수익 0원 방지용: 실제 체결이 완료된 매수 평단가 박제
val purchasePrice = double("purchase_price").default(0.0)
val status = varchar("status", 20) val status = varchar("status", 20)
val orderQty = integer("order_qty").default(0) val orderQty = integer("order_qty").default(0)
val reason = text("reason") // AI 판단 전문 val reason = text("reason") // AI 판단 전문
@ -63,7 +70,7 @@ object TradeHistoryTable : Table("trade_history") {
val technicalScore = double("technical_score").nullable() val technicalScore = double("technical_score").nullable()
val investmentGrade = text("investment_grade").nullable() // 투자 등급 (S, A, B...) val investmentGrade = text("investment_grade").nullable() // 투자 등급 (S, A, B...)
val holdingAvgPrice = double("holding_avg_price").default(0.0) val holdingAvgPrice = double("holding_avg_price").default(0.0)
override val primaryKey = PrimaryKey(id) // override val primaryKey = PrimaryKey(id)
} }
// [4] 체결 상세 (그대로 유지) // [4] 체결 상세 (그대로 유지)

View File

@ -206,19 +206,14 @@ object AutoTradingManager {
scope.launch { scope.launch {
var basePrice = decision.currentPrice var basePrice = decision.currentPrice
val tickSize = MarketUtil.getTickSize(basePrice) val tickSize = MarketUtil.getTickSize(basePrice)
// 등급별 가이드에 따라 매수 호가 설정
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt()) val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
var stockCode = decision.stockCode var stockCode = decision.stockCode
var stockName = decision.stockName var stockName = decision.stockName
val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble()) val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble())
val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt() val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt()
// 💡 2. 매수 실행 전, 안전장치 통과 여부 확인
if (!canAddNewPosition(maxStocks)) { if (!canAddNewPosition(maxStocks)) {
// 제한에 걸렸다면, 매수 로직을 건너뛰고 매도(보유 종목 관리) 로직으로만 넘어갑니다.
println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.") println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.")
// UI나 로그에 상태를 띄워주면 좋습니다.
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단") TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
@ -227,14 +222,11 @@ object AutoTradingManager {
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo -> .onSuccess { realOrderNo ->
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice") println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo") TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
// 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장)
val sRate = -1.5 val sRate = -1.5
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX) var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
// 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기
val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0)) val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
@ -301,17 +293,18 @@ object AutoTradingManager {
if (dbItem != null && execData != null && execData.isFilled) { if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) { if (dbItem.status == TradeStatus.PENDING_BUY) {
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환) // 1. 진짜 사온 가격 (실제 매수 체결가)
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 💡 [수정] 매수 주문(orderNo)에 대해 '진짜 산 가격'을 기록해야 합니다.
// 기존에는 여기에 finalTargetPrice를 넣으셨는데, 그러면 매수 단가가 오염됩니다.
TradingReportManager.updateExecution(orderNo, actualBuyPrice, dbItem.quantity)
val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05 val absoluteMinRate = KisSession.config.getValues(ConfigIndex.TAX_INDEX) + 0.05
val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate) val finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
// 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
TradingReportManager.updateExecution(orderNo,finalTargetPrice,dbItem.quantity) println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}")
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
KisTradeService.postOrder( KisTradeService.postOrder(
stockCode = dbItem.code, stockCode = dbItem.code,
@ -319,29 +312,33 @@ object AutoTradingManager {
price = finalTargetPrice.toLong().toString(), price = finalTargetPrice.toLong().toString(),
isBuy = false isBuy = false
).onSuccess { newSellOrderNo -> ).onSuccess { newSellOrderNo ->
// 익절가 업데이트 및 상태 변경 // 💡 [매도 주문 기록] 이제 팔기 시작했다는 의사결정을 리포트에 남깁니다.
TradingReportManager.recordTradeDecision( TradingReportManager.recordTradeDecision(
orderNo = newSellOrderNo, orderNo = newSellOrderNo,
stockCode = dbItem.code, stockCode = dbItem.code,
stockName = dbItem.name, stockName = dbItem.name,
isBuy = false, isBuy = false,
orderQty = dbItem.quantity, orderQty = dbItem.quantity,
reason = "🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)", // AI 이유 reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)}% 도달을 위한 익절 주문",
decision = null // AI 객체 통째로 전달 holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다!
decision = null
) )
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
executionCache.remove(orderNo) executionCache.remove(orderNo)
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}")
} }
} else if (dbItem.status == TradeStatus.SELLING) { } else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") // ✅ 2. 매도 완료 시점 (실제 매도 체결가)
myOredsAndBalanceCodes.remove(dbItem.code) val actualSellPrice = execData.price.toDoubleOrNull() ?: 0.0
val actualSellQty = execData.qty.toIntOrNull() ?: dbItem.quantity
// 💡 매도 주문번호에 대해 '진짜 판 가격'을 기록
TradingReportManager.updateExecution(orderNo, actualSellPrice, actualSellQty)
println("🎊 [매칭 성공] 매도 완료: ${dbItem.name} | 매도가: ${actualSellPrice.toInt()}")
myOredsAndBalanceCodes.remove(dbItem.code)
TradingReportManager.closePositionCycle(dbItem.code) // 사이클 종료 알림
TradingReportManager.updateExecution(orderNo,execData.price.toDouble(),execData.qty.toInt())
TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo) executionCache.remove(orderNo)
} }
@ -375,7 +372,7 @@ object AutoTradingManager {
} }
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
balance.getHolldings().forEach { holding -> balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}") println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
@ -437,6 +434,20 @@ object AutoTradingManager {
} }
} else { } else {
if ("Y".equals(marketCode)) { if ("Y".equals(marketCode)) {
if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0
&& holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)
&& holding.profitRate.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE)
&& holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) {
println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}")
val profit = holding.profitRate.toDouble()
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함."
)
}
analyzeDeepLossHoldingsAfterMarket(holding) analyzeDeepLossHoldingsAfterMarket(holding)
} }
} }
@ -453,7 +464,7 @@ object AutoTradingManager {
println("resumePendingSellOrders") println("resumePendingSellOrders")
balance.getHolldings().forEach { holding -> balance.getHoldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}") println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
@ -487,6 +498,19 @@ object AutoTradingManager {
"SELL", "SELL",
"🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료" "🎊 보유 주식[예상수익 : ${holding.profitRate}] ${if (isBefore930) "09:30 이전 현시세{${holding.currentPrice}}로 매도[$targetPrice] 주문" else "09:30 이후 시세{${holding.currentPrice}} 기준 호가 위 매도[$targetPrice] 주문"} 완료"
) )
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = newOrderNo,
code = holding.code,
name = holding.name,
quantity = holding.quantity.toInt(),
profitRate = 0.0,
stopLossRate = 0.0,
targetPrice = targetPrice.toDouble(),
stopLossPrice = 0.0,
status = "SELLING",
isDomestic = true
))
syncAndExecute(newOrderNo)
}.onFailure { }.onFailure {
TradingLogStore.addSellLog( TradingLogStore.addSellLog(
holding.code, holding.code,
@ -496,7 +520,21 @@ object AutoTradingManager {
) )
} }
} else { } else {
analyzeDeepLossHoldingsAfterMarket(holding) if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0
&& holding != null && holding.quantity.toInt() > 0
&& holding.availOrderCount.toInt() > 0
&& holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)
&& holding.profitRate.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE)
&& holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) {
println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}")
val profit = holding.profitRate.toDouble()
TradingLogStore.addNotice(
"보유주식[${holding.name}]",
holding.code,
"수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함."
)
}
analyzeDeepLossHoldingsAfterMarket(holding , true)
} }
delay(200) // API 호출 부하 방지 delay(200) // API 호출 부하 방지
} }
@ -504,10 +542,10 @@ object AutoTradingManager {
} }
} }
private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding, isForce : Boolean = false) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석
val now = LocalTime.now() val now = LocalTime.now()
val currentMinute = now.minute val currentMinute = now.minute
if ((now.hour == 8 || now.hour == 16 || now.hour == 17)) { if ((!isForce && (now.hour == 8 || now.hour == 16 || now.hour == 17)) || (isForce && (currentMinute % 15 == 0 ))) {
val profit = holding.profitRate.toDouble() val profit = holding.profitRate.toDouble()
val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다) val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다)
if (profit <= lossThreshold) { if (profit <= lossThreshold) {
@ -532,10 +570,10 @@ object AutoTradingManager {
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간 // 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) { if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) {
advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)" advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
TradingLogStore.addAnalyzer( TradingLogStore.addNotice(
"보유주식[${holding.name}]", "보유주식[${holding.name}]",
holding.code, holding.code,
"수익률($profit%) -> $advice", true "수익률($profit%) -> $advice"
) )
} }
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때 // 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
@ -622,6 +660,7 @@ object AutoTradingManager {
val H15M30 = LocalTime.of(15, 30) val H15M30 = LocalTime.of(15, 30)
val H16 = LocalTime.of(16, 0) val H16 = LocalTime.of(16, 0)
val H18 = LocalTime.of(18, 0) val H18 = LocalTime.of(18, 0)
val H20 = LocalTime.of(20, 0)
val H08M00 = LocalTime.of(8, 0) val H08M00 = LocalTime.of(8, 0)
val H08M45 = LocalTime.of(8, 45) val H08M45 = LocalTime.of(8, 45)
val H07M50 = LocalTime.of(7, 50) val H07M50 = LocalTime.of(7, 50)
@ -634,10 +673,10 @@ object AutoTradingManager {
currentTimeMillis = System.currentTimeMillis() currentTimeMillis = System.currentTimeMillis()
lastTickTime.set(System.currentTimeMillis()) // 생존 신고 lastTickTime.set(System.currentTimeMillis()) // 생존 신고
when { when {
now.isAfter(H18) || now.isBefore(H07M50) -> { now.isAfter(H20) || now.isBefore(H07M50) -> {
prepareMarketOpen(now) prepareMarketOpen(now)
} }
now.isBefore(H18) && now.isAfter(H08M00) -> { now.isBefore(H20) && now.isAfter(H08M00) -> {
waitTime = 0.2 waitTime = 0.2
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
@ -651,7 +690,7 @@ object AutoTradingManager {
} }
withTimeout(CYCLE_TIMEOUT) { withTimeout(CYCLE_TIMEOUT) {
println("⏱️ [Cycle Start] ${LocalTime.now()}") println("⏱️ [Cycle Start] ${LocalTime.now()}")
if (now.isAfter(H18)) { if (now.isAfter(H20)) {
executeClosingLiquidation(KisTradeService) executeClosingLiquidation(KisTradeService)
} else { } else {
executeMarketLoop() executeMarketLoop()
@ -674,7 +713,7 @@ object AutoTradingManager {
} }
suspend fun prepareMarketOpen(now : LocalTime) { suspend fun prepareMarketOpen(now : LocalTime) {
if (now.isAfter(H18) || now.isBefore(H07M50)) { if (now.isAfter(H20) || now.isBefore(H07M50)) {
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.") println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
onMarketClosed?.invoke() onMarketClosed?.invoke()
RagService.clearDailyCache() RagService.clearDailyCache()
@ -717,7 +756,12 @@ object AutoTradingManager {
if (isMorning) { if (isMorning) {
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull() currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
currentBalance?.let { currentBalance -> currentBalance?.let { currentBalance ->
TradingReportManager.recordAssetSnapshot(if (LocalTime.now().isAfter(LocalTime.of(17,58))) SnapshotType.END else SnapshotType.MIDDLE ,currentBalance,"") if (LocalTime.now().isBefore(LocalTime.of(16,2))) {
TradingReportManager.recordAssetSnapshot(
if (LocalTime.now().isAfter(LocalTime.of(17, 59))
) SnapshotType.END else SnapshotType.MIDDLE, currentBalance, ""
)
}
} }
if (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } if (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
@ -729,7 +773,7 @@ object AutoTradingManager {
myOredsAndBalanceCodes.clear() myOredsAndBalanceCodes.clear()
checkBalance() checkBalance()
val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings = currentBalance?.getHolldings()?.map { val myHoldings = currentBalance?.getHoldings()?.map {
myOredsAndBalanceCodes.add(it.code) myOredsAndBalanceCodes.add(it.code)
it.code }?.toSet() ?: emptySet() it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
@ -810,7 +854,7 @@ object AutoTradingManager {
lastForceCheckMinute = currentMinute // 실행 완료 기록 lastForceCheckMinute = currentMinute // 실행 완료 기록
} }
} }
else if((now.hour == 8 || now.hour == 16 || now.hour == 17 || now.hour == 18 || now.hour == 19) && (currentMinute % 2 == 1)) { else if((now.hour == 8 || (now.hour >= 16 && now.hour < 20)) && (currentMinute % 2 == 1)) {
if (lastForceCheckMinute != currentMinute) { if (lastForceCheckMinute != currentMinute) {
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
" - ", " - ",
@ -819,7 +863,7 @@ object AutoTradingManager {
true true
) )
var list = mutableListOf<String>("X") var list = mutableListOf<String>("X")
if (now.hour != 8) { if (now.hour != 8 && now.hour < 18) {
list.add("Y") list.add("Y")
} }
list.forEach { code -> list.forEach { code ->
@ -901,22 +945,15 @@ object AutoTradingManager {
} }
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope { private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
listOf( listOf(
// async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME0, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.RISE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FALL, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.RISE2, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.FALL2, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VALUE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.VOLUME_POWER, true).getOrDefault(emptyList()) },
// async { tradeService.fetchMarketRanking(RankingType.NEW_HIGH, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.COMPANY_TRADE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.FINANCE, true).getOrDefault(emptyList()) },
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) }, async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
@ -948,7 +985,7 @@ object AutoTradingManager {
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
val activeTrades = DatabaseFactory.findAllMonitoringTrades() val activeTrades = DatabaseFactory.findAllMonitoringTrades()
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
val realHoldings = balanceResult?.getHolldings()?.associateBy { it.code } ?: emptyMap() val realHoldings = balanceResult?.getHoldings()?.associateBy { it.code } ?: emptyMap()
activeTrades.forEach { trade -> activeTrades.forEach { trade ->
try { try {
@ -988,16 +1025,6 @@ object AutoTradingManager {
} }
} }
fun checkAndRestart() {
if (!isRunning()) {
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
startAutoDiscoveryLoop()
} else {
}
}
} }

View File

@ -219,7 +219,6 @@ fun TradingDecisionLog() {
// ========================================== // ==========================================
// 1⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3) // 1⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3)
// ========================================== // ==========================================
var firstSet = mutableSetOf<ConfigIndex>()
item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지 item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지
Text( Text(
@ -247,13 +246,7 @@ fun TradingDecisionLog() {
if (configKey.label.contains("PROFIT")) { if (configKey.label.contains("PROFIT")) {
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
} }
if (firstSet.contains(configKey)) {
TradingLogStore.addSettingLog(configKey.label, oldValue.toString(), newValue.toString(), "💾 저장됨: ${configKey.label} = $newValue")
} else {
firstSet.add(configKey)
}
KisSession.config.setValues(configKey, newValue) KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
} }
var text = if (configKey.label.contains("PROFIT")) { var text = if (configKey.label.contains("PROFIT")) {
@ -295,8 +288,10 @@ fun TradingDecisionLog() {
item(span = { GridItemSpan(3) }) { item(span = { GridItemSpan(3) }) {
SettingSwitchField( SettingSwitchField(
label = "자동 익절 활성화", label = "자동 익절 활성화",
initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) == 1.0, initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) > 0.0,
onCheckedChange = { KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0) }, onCheckedChange = {
KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0)
},
helperText = "목표 수익률 도달 시 기계적 익절" helperText = "목표 수익률 도달 시 기계적 익절"
) )
} }
@ -304,7 +299,7 @@ fun TradingDecisionLog() {
item(span = { GridItemSpan(3) }) { item(span = { GridItemSpan(3) }) {
SettingSwitchField( SettingSwitchField(
label = "자동 손절 활성화", label = "자동 손절 활성화",
initialChecked = KisSession.config.getValues(ConfigIndex.STOP_LOSS) == 1.0, initialChecked = KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0,
onCheckedChange = { KisSession.config.setValues(ConfigIndex.STOP_LOSS, if (it) 1.0 else 0.0) }, onCheckedChange = { KisSession.config.setValues(ConfigIndex.STOP_LOSS, if (it) 1.0 else 0.0) },
helperText = "손실 방어선 도달 시 기계적 손절" helperText = "손실 방어선 도달 시 기계적 손절"
) )
@ -389,12 +384,6 @@ fun TradingDecisionLog() {
var oldValue = KisSession.config.getValues(configKey) var oldValue = KisSession.config.getValues(configKey)
var newValue = localText.toDoubleOrNull() ?: 0.0 var newValue = localText.toDoubleOrNull() ?: 0.0
KisSession.config.setValues(configKey, newValue) KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
if (firstSet.contains(configKey)) {
TradingLogStore.addSettingLog(configKey.label, oldValue.toString(), newValue.toString(), "💾 저장됨: ${configKey.label} = $newValue")
} else {
firstSet.add(configKey)
}
} }
// labelText 업데이트 로직 (기존과 동일) // labelText 업데이트 로직 (기존과 동일)