...
This commit is contained in:
parent
ed12d07bc2
commit
2d577300c3
@ -9,6 +9,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import report.TradingReportManager
|
||||
import report.TradingReportService
|
||||
import report.database.AssetSnapshotTable
|
||||
import report.database.ConfigHistoryTable
|
||||
import report.database.ExecutionDetailsTable
|
||||
import report.database.SnapshotHoldingsTable
|
||||
import report.database.TradeHistoryTable
|
||||
@ -139,6 +140,7 @@ object DatabaseFactory {
|
||||
|
||||
transaction(reportDb) {
|
||||
SchemaUtils.createMissingTablesAndColumns(
|
||||
ConfigHistoryTable,
|
||||
AssetSnapshotTable,
|
||||
SnapshotHoldingsTable,
|
||||
TradeHistoryTable,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
package model
|
||||
|
||||
import report.TradingReportManager
|
||||
import java.time.LocalDateTime
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
enum class ConfigIndex(val index : Int,val label : String) {
|
||||
@ -118,8 +119,9 @@ data class AppConfig(
|
||||
return if (isSimulation) vtsAccountNo else realAccountNo
|
||||
}
|
||||
|
||||
|
||||
var firstSet = mutableSetOf<ConfigIndex>()
|
||||
fun setValues(index :ConfigIndex , value : Double) {
|
||||
val oldValue = getValues(index)
|
||||
when (index) {
|
||||
ConfigIndex.TAX_INDEX -> {FEES_AND_TAXRATE = value}
|
||||
ConfigIndex.PROFIT_INDEX -> {MINIMUM_NET_PROFIT = value}
|
||||
@ -155,6 +157,18 @@ data class AppConfig(
|
||||
ConfigIndex.TAKE_PROFIT -> { take_profit = value > 0.1 }
|
||||
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 {
|
||||
return when (index) {
|
||||
@ -196,9 +210,9 @@ data class AppConfig(
|
||||
ConfigIndex.GRADE_3_ALLOCATIONRATE -> {GRADE_3_ALLOCATIONRATE}
|
||||
ConfigIndex.GRADE_2_ALLOCATIONRATE -> {GRADE_2_ALLOCATIONRATE}
|
||||
ConfigIndex.GRADE_1_ALLOCATIONRATE -> {GRADE_1_ALLOCATIONRATE}
|
||||
ConfigIndex.LOSS_MAXRATE -> { loss_max}
|
||||
ConfigIndex.LOSS_MINRATE -> { loss_min}
|
||||
ConfigIndex.LOSS_MAX_MONEY -> { loss_money }
|
||||
ConfigIndex.LOSS_MAXRATE -> { abs(loss_max) * -1}
|
||||
ConfigIndex.LOSS_MINRATE -> { abs(loss_min) * -1}
|
||||
ConfigIndex.LOSS_MAX_MONEY -> { abs(loss_money) * -1}
|
||||
ConfigIndex.STOP_LOSS -> {if(!stop_Loss) 0.0 else 1.0}
|
||||
ConfigIndex.TAKE_PROFIT -> {if(!take_profit) 0.0 else 1.0}
|
||||
ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()}
|
||||
|
||||
@ -3,38 +3,43 @@ package model
|
||||
import AutoTradeItem
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class StockBalanceResponse(
|
||||
val rt_cd: String = "",
|
||||
val msg1: String = "",
|
||||
val ctx_area_fk100: String = "",
|
||||
val ctx_area_nk100: String = "",
|
||||
val output1: List<StockHolding> = emptyList(),
|
||||
val output2: List<BalanceSummary> = emptyList()
|
||||
val output1: List<StockHolding> = emptyList(), // 종목별 잔고
|
||||
val output2: List<BalanceSummary> = emptyList() // 계좌 요약
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StockHolding(
|
||||
val pdno: String = "", // 상품번호
|
||||
val pdno: String = "", // 상품번호 (종목코드)
|
||||
val prdt_name: String = "", // 상품명
|
||||
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 evlu_amt: String = "0", // 평가금액
|
||||
val evlu_pfls_amt: String = "0", // 평가손익금액 (평가금액 - 매입금액)
|
||||
val evlu_pfls_rt: String = "0.0", // 평가손익률
|
||||
val evlu_amt: String = "0" , // 평가금액
|
||||
val ord_psbl_qty : String = "0",
|
||||
val thdt_buyqty : String = "0",
|
||||
val fltt_rt: String = "0.0", // 등락율 (당일 시장 강도 분석용)
|
||||
val bfdy_cprs_icdc: String = "0", // 전일대비증감 (수급 확인용)
|
||||
val ord_psbl_qty: String = "0", // 주문가능수량
|
||||
val thdt_buyqty: String = "0" // 금일매수수량
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BalanceSummary(
|
||||
val tot_evlu_amt: String = "0", // 총 평가금액
|
||||
val evlu_pfls_rt: String = "0.0", // 총 수익률 (에러 발생 지점: 기본값 추가로 해결)
|
||||
val asst_icrt: String = "0.0", // 일부 환경에서 수익률 필드명
|
||||
val nass_amt: String = "0" , // 순자산 금액
|
||||
val dnca_tot_amt: String = "0"
|
||||
val dnca_tot_amt: String = "0", // 예수금총금액
|
||||
val tot_evlu_amt: String = "0", // 총평가금액 (자산 총계)
|
||||
val pchs_amt_smtl_amt: String = "0", // 매입금액합계금액
|
||||
val evlu_pfls_smtl_amt: String = "0", // 평가손익합계금액
|
||||
val asst_icdc_amt: String = "0", // 자산증감액 (어제 대비 성적 - 리포트 핵심)
|
||||
val thdt_tlex_amt: String = "0" // 금일제비용금액 (세금/수수료 - 순수익 계산용)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RankingResponse(
|
||||
var rt_cd : String,
|
||||
@ -140,36 +145,39 @@ data class OverseasRankingStock(
|
||||
prdy_ctrt = rate
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UnifiedStockHolding(
|
||||
val code: String, // 종목코드
|
||||
val name: String, // 종목명
|
||||
val quantity: String, // 보유수량
|
||||
val avgPrice: String, // 매입단가
|
||||
val currentPrice: String, // 현재가
|
||||
val profitRate: String, // 수익률
|
||||
val evalAmount: String, // 평가금액
|
||||
val isDomestic: Boolean, // 국내/해외 구분
|
||||
val availOrderCount : String,
|
||||
val thdtBuyQty: String,
|
||||
val avgPrice: String, // 매입단가 (pchs_avg_pric)
|
||||
val currentPrice: String, // 현재가 (prpr)
|
||||
val profitRate: String, // 수익률 (evlu_pfls_rt)
|
||||
val evalAmount: String, // 평가금액 (evlu_amt)
|
||||
val valuationProfitAmount: String, // 평가손익금액 (evlu_pfls_amt)
|
||||
val isDomestic: Boolean, // 국내/해외 구분
|
||||
val availOrderCount: String, // 주문가능수량
|
||||
val thdtBuyQty: String, // 금일매수수량
|
||||
|
||||
){
|
||||
// 당일 매수 여부 판별 (금일 매수 수량이 0보다 크면 당일 진입 종목)
|
||||
val isTodayEntry: Boolean get() = thdtBuyQty.toInt() > 0
|
||||
// 추가 추천 필드
|
||||
val dailyChangeRate: String = "0.0", // 당일 등락율 (fltt_rt)
|
||||
val pchsAmount: String = "0" // 총 매입금액 (pchs_amt)
|
||||
) {
|
||||
val isTodayEntry: Boolean get() = thdtBuyQty.toIntOrNull() ?: 0 > 0
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UnifiedBalance(
|
||||
val totalAsset: String, // 총 평가자산
|
||||
val totalProfitRate: String, // 총 수익률
|
||||
val deposit: String,
|
||||
private val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트
|
||||
val totalAsset: String, // 총 평가자산
|
||||
val deposit: String, // 예수금
|
||||
val dailyAssetChange: String, // 당일 자산 증감
|
||||
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
|
||||
data class UnfilledOrder(
|
||||
val orgn_odno: String,
|
||||
|
||||
@ -83,47 +83,75 @@ object KisTradeService {
|
||||
// 국내와 해외 잔고를 비동기로 동시 호출
|
||||
val domesticJob = async { fetchDomesticRawBalance(marketCode) }
|
||||
val overseasJob = async { fetchOverseasRawBalance() }
|
||||
|
||||
try {
|
||||
val domRes = domesticJob.await().getOrNull()
|
||||
val ovsRes = overseasJob.await().getOrNull()
|
||||
|
||||
val combinedHoldings = mutableListOf<UnifiedStockHolding>()
|
||||
|
||||
// 국내 종목 매핑
|
||||
// 1. 국내 종목 매핑 (신규 추가된 모델 파라미터 반영)
|
||||
domRes?.output1?.forEach {
|
||||
combinedHoldings.add(UnifiedStockHolding(
|
||||
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
|
||||
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
|
||||
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 {
|
||||
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 {
|
||||
combinedHoldings.add(UnifiedStockHolding(
|
||||
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
|
||||
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
|
||||
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) +
|
||||
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
|
||||
val depositAmt = domRes?.output2?.firstOrNull()?.dnca_tot_amt?.toLongOrNull() ?: 0L
|
||||
// 4. 최종 통합 잔고 반환
|
||||
val domSummary = domRes?.output2?.firstOrNull()
|
||||
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(
|
||||
totalAsset = String.format("%,d", totalAmt),
|
||||
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
|
||||
deposit = String.format("%,d", depositAmt),
|
||||
totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)),
|
||||
deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L),
|
||||
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
|
||||
))
|
||||
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e) }
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,166 +1,477 @@
|
||||
package report
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.Duration
|
||||
|
||||
object LocalReportGenerator {
|
||||
|
||||
// 💡 모든 거래 내역의 상세 지표를 담는 DTO
|
||||
data class TradeDetailData(
|
||||
val isBuy: Boolean, // 매수/매도 구분
|
||||
val stockName: String,
|
||||
val orderTime: String, // 주문 시각
|
||||
val timeTaken: String, // 처리 완료 총 시간 또는 미완료 상태
|
||||
val execQty: Int, // 처리량 (체결 수량)
|
||||
val avgPrice: Long, // 체결 평균 단가
|
||||
val profitRate: Double, // 수익률 (매도 시에만 유효)
|
||||
val profitAmount: Long, // 수익금액 (매도 시에만 유효)
|
||||
val reason: String,
|
||||
val investmentGrade: String?,
|
||||
val aiScore: Double?
|
||||
private val reportScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// --- [Raw 데이터 모델 유지] ---
|
||||
data class RawSummaryData(
|
||||
val type: String,
|
||||
val startAsset: Long,
|
||||
val endAsset: Long,
|
||||
val dailyAssetChange: Long,
|
||||
val todayFees: Long,
|
||||
val totalProfitRate: Double
|
||||
)
|
||||
|
||||
fun generateAndOpen(startAsset: Long, endAsset: Long, tradeLogs: List<TradeDetailData>) {
|
||||
val today = LocalDate.now().toString()
|
||||
val profitAmount = endAsset - startAsset
|
||||
val profitRate = if (startAsset > 0) (profitAmount.toDouble() / startAsset) * 100 else 0.0
|
||||
data class RawHoldingData(
|
||||
val stockName: String,
|
||||
val quantity: Int,
|
||||
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 행 생성
|
||||
val tradeRowsHtml = if (tradeLogs.isEmpty()) {
|
||||
"<tr><td colspan='8' style='padding: 30px; text-align: center; color: #999;'>금일 발생한 주문 내역이 없습니다.</td></tr>"
|
||||
} else {
|
||||
tradeLogs.joinToString("\n") { trade ->
|
||||
// 매수(빨강), 매도(파랑) 뱃지
|
||||
val typeBadge = if (trade.isBuy) "<span class='badge type-buy'>매수</span>" else "<span class='badge type-sell'>매도</span>"
|
||||
data class RawExecutionData(
|
||||
val price: Double,
|
||||
val quantity: Int,
|
||||
val execTime: String
|
||||
)
|
||||
|
||||
// 시간 포맷팅
|
||||
val orderTimeParsed = LocalDateTime.parse(trade.orderTime).format(timeFormatter)
|
||||
data class RawTradeData(
|
||||
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)}%"
|
||||
val rateColor = if (trade.isBuy || trade.profitRate == 0.0) "#333" else if (trade.profitRate > 0) "#FF3B30" else "#007AFF"
|
||||
val amountText = if (trade.isBuy) "-" else "${String.format("%,d", trade.profitAmount)}원"
|
||||
// 💡 내부 대시보드 통계용 데이터 클래스
|
||||
private data class DashboardStats(
|
||||
val buyOrderCount: Int, // 금일 총 매수 주문 횟수
|
||||
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
|
||||
)
|
||||
|
||||
// 상태/시간 표시 처리
|
||||
val timeStatusHtml = if (trade.timeTaken.contains("미완료")) {
|
||||
"<span style='color: #FF9500; font-weight: bold;'>${trade.timeTaken}</span>"
|
||||
} else {
|
||||
"<span style='color: #888;'>${trade.timeTaken}</span>"
|
||||
var lastSavedTime = -1L
|
||||
val reportOpenTime = 60 * 1000 * 60
|
||||
fun generateAndOpenAsync(
|
||||
summary: RawSummaryData,
|
||||
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 }
|
||||
|
||||
"""
|
||||
<tr>
|
||||
<td class="center">$typeBadge</td>
|
||||
<td class="stock-name"><strong>${trade.stockName}</strong> $gradeHtml</td>
|
||||
<td class="center">$orderTimeParsed</td>
|
||||
<td class="center">$timeStatusHtml</td>
|
||||
<td class="right">${String.format("%,d", trade.execQty)}주</td>
|
||||
<td class="right">${String.format("%,d", trade.avgPrice)}원</td>
|
||||
<td class="right rate" style="color: $rateColor;">$rateText</td>
|
||||
<td class="right rate" style="color: $rateColor;">$amountText</td>
|
||||
</tr>
|
||||
<tr class="reason-row">
|
||||
<td colspan="8">
|
||||
<div class="reason-box">
|
||||
<span class="reason-icon">💡</span>
|
||||
<span class="reason-text"><strong>AI (${String.format("%.1f", trade.aiScore ?: 0.0)}점):</strong> ${trade.reason.replace("\n", " ")}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
""".trimIndent()
|
||||
var buyCount = 0
|
||||
var sellCount = 0
|
||||
var completed = 0
|
||||
var pending = 0
|
||||
var totalProfit = 0L
|
||||
var totalRateSum = 0.0
|
||||
var closedTradeCount = 0
|
||||
|
||||
var bestName = "-"
|
||||
var bestProfit = Long.MIN_VALUE
|
||||
|
||||
tradesByStock.forEach { (_, stockTrades) ->
|
||||
val buys = stockTrades.filter { it.isBuy }
|
||||
val sells = stockTrades.filter { !it.isBuy }
|
||||
|
||||
buyCount += buys.size
|
||||
sellCount += sells.size
|
||||
|
||||
// 1. 청산 완료 여부 판별
|
||||
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 템플릿
|
||||
val htmlTemplate = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ATRADE 일간 리포트 - $today</title>
|
||||
<style>
|
||||
:root { --red: #FF3B30; --blue: #007AFF; --bg: #f4f7f6; --text: #333; }
|
||||
body { font-family: 'Pretendard', 'Apple SD Gothic Neo', sans-serif; background-color: var(--bg); color: var(--text); padding: 40px 20px; margin: 0; }
|
||||
.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); }
|
||||
.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()
|
||||
return DashboardStats(
|
||||
buyOrderCount = buyCount,
|
||||
sellOrderCount = sellCount,
|
||||
completedCycles = completed,
|
||||
pendingCycles = pending,
|
||||
totalRealizedProfit = totalProfit,
|
||||
avgRealizedProfit = if (closedTradeCount > 0) totalProfit / closedTradeCount else 0L,
|
||||
avgRealizedRate = if (closedTradeCount > 0) totalRateSum / closedTradeCount else 0.0,
|
||||
winRate = if (closedTradeCount > 0) (tradesByStock.filter { /* 승리판별로직 */ true }.size.toDouble()) /* 실제 승률 계산 필요 시 추가 */ else 0.0,
|
||||
bestTradeName = bestName,
|
||||
bestTradeProfit = if (bestProfit == Long.MIN_VALUE) 0L else bestProfit
|
||||
)
|
||||
}
|
||||
|
||||
val directory = File("reports")
|
||||
if (!directory.exists()) directory.mkdirs()
|
||||
val reportFile = File(directory, "ATRADE_Report_$today.html")
|
||||
// --- [탭 2, 3 처리 로직] (이전과 거의 동일) ---
|
||||
private fun processHoldings(rawHoldings: List<RawHoldingData>): String {
|
||||
if (rawHoldings.isEmpty()) return "<tr><td colspan='8'>현재 보유 중인 종목이 없습니다.</td></tr>"
|
||||
|
||||
try {
|
||||
reportFile.writeText(htmlTemplate, Charsets.UTF_8)
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(reportFile.toURI())
|
||||
return rawHoldings.joinToString("\n") { h ->
|
||||
val valuationProfit = ((h.currentPrice - h.avgPrice) * h.quantity).toLong()
|
||||
val profitRate = if (h.avgPrice > 0) ((h.currentPrice - h.avgPrice) / h.avgPrice) * 100 else 0.0
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,13 @@
|
||||
package report
|
||||
|
||||
import io.ktor.utils.io.core.String
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@ -8,11 +15,12 @@ import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import report.database.*
|
||||
import model.*
|
||||
import network.KisTradeService
|
||||
import service.InvestmentGrade
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
||||
|
||||
enum class SnapshotType { START, END, MIDDLE }
|
||||
|
||||
@ -50,6 +58,182 @@ object TradingReportManager : TradingReportService {
|
||||
// Key: 종목코드, Value: 현재 활성화된 Position ID
|
||||
private val activePositions = mutableMapOf<String, String>()
|
||||
|
||||
override fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String?) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val todayDate = LocalDate.now().toString()
|
||||
|
||||
// 1. 중복 없는 전체 종목 코드 리스트 추출
|
||||
val allCodes = mutableSetOf<String>()
|
||||
val holdings = balance.getHoldings()
|
||||
allCodes.addAll(holdings.map { it.code })
|
||||
|
||||
// 거래 내역 테이블에서 당일 거래된 종목 코드 추가
|
||||
val tradedCodes = transaction(DatabaseFactory.reportDb) {
|
||||
TradeHistoryTable.select { TradeHistoryTable.orderTime like "$todayDate%" }
|
||||
.map { it[TradeHistoryTable.stockCode] }
|
||||
}
|
||||
allCodes.addAll(tradedCodes)
|
||||
|
||||
// 2. [핵심] 시세 데이터 일괄 병렬 조회 (Cache 구성)
|
||||
val priceCache = mutableMapOf<String, CandleData?>()
|
||||
if (type == SnapshotType.END) {
|
||||
allCodes.chunked(10).forEach { batch -> // API 부하 조절을 위해 10개씩 묶음 처리 가능
|
||||
batch.map { code ->
|
||||
async {
|
||||
code to KisTradeService.fetchPeriodChartData(code, "D", true).getOrNull()?.lastOrNull()
|
||||
}
|
||||
}.awaitAll().forEach { (code, data) ->
|
||||
priceCache[code] = data
|
||||
}
|
||||
delay(200) // API 초당 호출 제한(TPS) 준수
|
||||
}
|
||||
}
|
||||
transaction(DatabaseFactory.reportDb) {
|
||||
// 1. 자산 스냅샷 저장
|
||||
val snapshotId = AssetSnapshotTable.insertAndGetId {
|
||||
it[AssetSnapshotTable.date] = todayDate
|
||||
it[AssetSnapshotTable.snapshotType] = type.name
|
||||
|
||||
// 기존 데이터 유지 부분
|
||||
it[AssetSnapshotTable.totalAsset] = balance.totalAsset.replace(",", "").toLongOrNull() ?: 0L
|
||||
it[AssetSnapshotTable.totalProfitRate] =
|
||||
balance.totalProfitRate.replace("%", "").toDoubleOrNull() ?: 0.0
|
||||
it[AssetSnapshotTable.deposit] =
|
||||
balance.deposit.replace(",", "").toLongOrNull() ?: 0L // 👈 [추가] 에러 원인 해결!
|
||||
|
||||
// 신규 리포트용 데이터 부분
|
||||
it[AssetSnapshotTable.dailyAssetChange] =
|
||||
balance.dailyAssetChange.replace(",", "").toLongOrNull() ?: 0L
|
||||
it[AssetSnapshotTable.todayFees] = balance.todayFees.replace(",", "").toLongOrNull() ?: 0L
|
||||
|
||||
// it[AssetSnapshotTable.appliedConfigJson] = "{}" // 👈 [추가] 스키마상 필수로 지정되어 있어 빈 값이라도 넣어줍니다.
|
||||
it[AssetSnapshotTable.remark] = remark
|
||||
}.value
|
||||
|
||||
// 2. 보유 종목 스냅샷 저장
|
||||
if (balance.getHoldings().isNotEmpty()) {
|
||||
SnapshotHoldingsTable.batchInsert(balance.getHoldings()) { stock ->
|
||||
this[SnapshotHoldingsTable.snapshotId] = snapshotId
|
||||
this[SnapshotHoldingsTable.code] = stock.code
|
||||
this[SnapshotHoldingsTable.name] = stock.name
|
||||
this[SnapshotHoldingsTable.quantity] = stock.quantity.toIntOrNull() ?: 0
|
||||
this[SnapshotHoldingsTable.avgPrice] = stock.avgPrice.toDoubleOrNull() ?: 0.0
|
||||
this[SnapshotHoldingsTable.currentPrice] = stock.currentPrice.toDoubleOrNull() ?: 0.0
|
||||
this[SnapshotHoldingsTable.profitRate] = stock.profitRate.toDoubleOrNull() ?: 0.0
|
||||
this[SnapshotHoldingsTable.evalAmount] = stock.evalAmount.replace(",", "").toLongOrNull() ?: 0L
|
||||
this[SnapshotHoldingsTable.isDomestic] = stock.isDomestic
|
||||
this[SnapshotHoldingsTable.isTodayEntry] = stock.isTodayEntry
|
||||
}
|
||||
}
|
||||
|
||||
val startAsset = AssetSnapshotTable.select {
|
||||
(AssetSnapshotTable.date eq todayDate) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
}.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()
|
||||
?.get(AssetSnapshotTable.totalAsset)
|
||||
?: balance.totalAsset.replace(",", "").toLongOrNull() ?: 0L
|
||||
|
||||
// 3. 🚀 요약 데이터 Raw 패키징
|
||||
val summaryData = LocalReportGenerator.RawSummaryData(
|
||||
type = type.name,
|
||||
startAsset = startAsset,
|
||||
endAsset = balance.totalAsset.replace(",", "").toLongOrNull() ?: 0L,
|
||||
dailyAssetChange = balance.dailyAssetChange.replace(",", "").toLongOrNull() ?: 0L,
|
||||
todayFees = balance.todayFees.replace(",", "").toLongOrNull() ?: 0L,
|
||||
totalProfitRate = balance.totalProfitRate.replace("%", "").toDoubleOrNull() ?: 0.0
|
||||
)
|
||||
|
||||
// 4. 🚀 보유 잔고 데이터 Raw 패키징 (수익률 계산은 Generator에 위임)
|
||||
val holdingLogs = balance.getHoldings().map { stock ->
|
||||
var o = 0.0;
|
||||
var h = 0.0;
|
||||
var l = 0.0
|
||||
var c = 0.0
|
||||
|
||||
// 💡 장 마감 리포트일 때만 시세 API 호출
|
||||
if (type == SnapshotType.END) {
|
||||
runBlocking {
|
||||
priceCache[stock.code]?.let {
|
||||
o = it.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
h = it.stck_hgpr.toDoubleOrNull() ?: 0.0
|
||||
l = it.stck_lwpr.toDoubleOrNull() ?: 0.0
|
||||
c = it.stck_prpr.toDoubleOrNull() ?: 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalReportGenerator.RawHoldingData(
|
||||
stockName = stock.name,
|
||||
quantity = stock.quantity.toIntOrNull() ?: 0,
|
||||
avgPrice = stock.avgPrice.toDoubleOrNull() ?: 0.0,
|
||||
currentPrice = stock.currentPrice.toDoubleOrNull() ?: 0.0,
|
||||
evalAmount = stock.evalAmount.replace(",", "").toLongOrNull() ?: 0L,
|
||||
openPrice = o, highPrice = h, lowPrice = l, closePrice = c,
|
||||
)
|
||||
}
|
||||
|
||||
// 5. 🚀 당일 거래 내역 Raw 패키징
|
||||
val tradeLogs = TradeHistoryTable.select {
|
||||
TradeHistoryTable.orderTime like "$todayDate%"
|
||||
}.orderBy(TradeHistoryTable.orderTime to SortOrder.ASC).map { row ->
|
||||
val code = row[TradeHistoryTable.stockCode]
|
||||
var o = 0.0;
|
||||
var h = 0.0;
|
||||
var l = 0.0;
|
||||
var c = 0.0
|
||||
|
||||
if (type == SnapshotType.END) {
|
||||
runBlocking {
|
||||
priceCache[code]?.let {
|
||||
o = it.stck_oprc.toDoubleOrNull() ?: 0.0
|
||||
h = it.stck_hgpr.toDoubleOrNull() ?: 0.0
|
||||
l = it.stck_lwpr.toDoubleOrNull() ?: 0.0
|
||||
c = it.stck_prpr.toDoubleOrNull() ?: 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
// 주의: 시간순(ASC)으로 정렬해서 넘겨야 제너레이터가 시간 흐름대로 묶기 편합니다.
|
||||
|
||||
val tradeId = row[TradeHistoryTable.id].value
|
||||
|
||||
val rawExecutions =
|
||||
ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.map { exec ->
|
||||
LocalReportGenerator.RawExecutionData(
|
||||
price = exec[ExecutionDetailsTable.price],
|
||||
quantity = exec[ExecutionDetailsTable.quantity],
|
||||
execTime = exec[ExecutionDetailsTable.execTime]
|
||||
)
|
||||
}
|
||||
|
||||
// 매도일 경우에만 매수 단가 추적 (DB 조회가 필요하므로 이 작업만 매니저가 수행)
|
||||
val isBuy = row[TradeHistoryTable.isBuy]
|
||||
val resolvedBuyPrice =
|
||||
if (!isBuy) findBuyPrice(row[TradeHistoryTable.stockCode], tradeId, todayDate) else 0.0
|
||||
|
||||
LocalReportGenerator.RawTradeData(
|
||||
stockCode = row[TradeHistoryTable.stockCode], // 💡 [추가] 제너레이터가 종목별로 묶을 수 있도록 코드값 전달
|
||||
stockName = row[TradeHistoryTable.stockName],
|
||||
isBuy = isBuy,
|
||||
status = row[TradeHistoryTable.status],
|
||||
orderTime = row[TradeHistoryTable.orderTime],
|
||||
executions = rawExecutions,
|
||||
currentPrice = row[TradeHistoryTable.currentPrice] ?: 0.0,
|
||||
resolvedBuyPrice = resolvedBuyPrice,
|
||||
investmentGrade = row[TradeHistoryTable.investmentGrade] ?: "-",
|
||||
reason = row[TradeHistoryTable.reason] ?: "",
|
||||
aiScore = row[TradeHistoryTable.aiScore] ?: 0.0,
|
||||
openPrice = o,
|
||||
highPrice = h,
|
||||
lowPrice = l,
|
||||
closePrice = c,
|
||||
)
|
||||
}
|
||||
|
||||
// 6. 코루틴 기반 제너레이터 호출
|
||||
LocalReportGenerator.generateAndOpenAsync(summaryData, holdingLogs, tradeLogs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun recordConfigChange() {
|
||||
transaction(DatabaseFactory.reportDb) {
|
||||
val now = LocalDateTime.now()
|
||||
@ -108,98 +292,72 @@ object TradingReportManager : TradingReportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [1] 자산 현황 및 보유 종목 스냅샷 저장
|
||||
* isClose 파라미터가 true로 들어오면 모든 기록을 마치고 자동으로 일간 리포트를 생성합니다.
|
||||
*/
|
||||
|
||||
override fun recordAssetSnapshot(
|
||||
type: SnapshotType,
|
||||
balance: UnifiedBalance,
|
||||
remark: String?
|
||||
) {
|
||||
transaction(DatabaseFactory.reportDb) {
|
||||
val todayDate = LocalDate.now().toString()
|
||||
// ==========================================
|
||||
// 3. 내부 유틸리티 함수
|
||||
// ==========================================
|
||||
|
||||
// 💡 [핵심 로직] 스냅샷 타입 자동 결정
|
||||
val actualSnapshotType = if (type == SnapshotType.END) {
|
||||
SnapshotType.END
|
||||
private fun calculateTimeTaken(orderTimeStr: String, lastExecTimeStr: String?, status: String): String {
|
||||
if (lastExecTimeStr == null) return "미체결 (진행중)"
|
||||
return try {
|
||||
val orderTime = LocalDateTime.parse(orderTimeStr)
|
||||
val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
|
||||
val duration = java.time.Duration.between(orderTime, lastExecTime)
|
||||
|
||||
val timeString = if (duration.toMinutes() > 0) {
|
||||
"${duration.toMinutes()}분 ${duration.seconds % 60}초"
|
||||
} else {
|
||||
// 오늘 날짜로 기록된 START 스냅샷이 있는지 검사
|
||||
val hasStartToday = AssetSnapshotTable.select {
|
||||
(AssetSnapshotTable.date eq todayDate) and
|
||||
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
}.limit(1).count() > 0
|
||||
|
||||
// 없으면 START, 이미 있으면 MIDDLE로 지정
|
||||
if (hasStartToday) SnapshotType.MIDDLE else SnapshotType.START
|
||||
}
|
||||
|
||||
// 1. 당시의 주요 설정값 백업 (기존 대표님 코드 유지)
|
||||
var buffer = StringBuffer()
|
||||
arrayOf(
|
||||
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
|
||||
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND, InvestmentGrade.LEVEL_2_HIGH_RISK, InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||
).forEach { grade ->
|
||||
buffer.appendLine("${grade.name}")
|
||||
.appendLine(" 매수 목표 : -${KisSession.config.getValues(grade.buyGuide)}호가, 목표 수익:${KisSession.config.getValues(ConfigIndex.TAX_INDEX) + (KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))}, 최대 투자:${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * KisSession.config.getValues(grade.allocationRate)}")
|
||||
}
|
||||
|
||||
val configJson = Json.encodeToString(
|
||||
ConfigSnapshot(
|
||||
targetProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) ?: 0.0,
|
||||
stopLoss = KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE) ?: 0.0,
|
||||
maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) ?: 0.0,
|
||||
guideScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) ?: 0.0,
|
||||
descMinMax = "종목 금액 필터 MIN:${KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) ?: 0.0} ~ MAX:${KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) ?: 0.0}",
|
||||
grades = buffer.toString()
|
||||
)
|
||||
)
|
||||
|
||||
// 2. 자산 마스터 데이터 저장 (실제 결정된 actualSnapshotType 사용)
|
||||
val snapshotId = AssetSnapshotTable.insert {
|
||||
it[date] = todayDate
|
||||
it[snapshotType] = actualSnapshotType.name
|
||||
it[totalAsset] = balance.totalAsset.toLongOrNull() ?: 0L
|
||||
it[totalProfitRate] = balance.totalProfitRate.toDoubleOrNull() ?: 0.0
|
||||
it[deposit] = balance.deposit.toLongOrNull() ?: 0L
|
||||
it[appliedConfigJson] = configJson
|
||||
it[this.remark] = remark ?: if (actualSnapshotType == SnapshotType.MIDDLE) "장중 상태 기록" else ""
|
||||
}[AssetSnapshotTable.id]
|
||||
|
||||
// 3. 보유 종목 리스트 일괄 저장 (Batch Insert)
|
||||
if (balance.getHolldings().isNotEmpty()) {
|
||||
SnapshotHoldingsTable.batchInsert(balance.getHolldings()) { stock ->
|
||||
this[SnapshotHoldingsTable.snapshotId] = snapshotId
|
||||
this[SnapshotHoldingsTable.code] = stock.code
|
||||
this[SnapshotHoldingsTable.name] = stock.name
|
||||
|
||||
// 포지션 ID 매핑: 메모리 맵에 없으면 신규 발급
|
||||
val posId = activePositions.getOrPut(stock.code) {
|
||||
"${stock.code}_${System.currentTimeMillis()}"
|
||||
}
|
||||
this[SnapshotHoldingsTable.positionId] = posId
|
||||
|
||||
this[SnapshotHoldingsTable.quantity] = stock.quantity.toIntOrNull() ?: 0
|
||||
this[SnapshotHoldingsTable.avgPrice] = stock.avgPrice.toDoubleOrNull() ?: 0.0
|
||||
this[SnapshotHoldingsTable.currentPrice] = stock.currentPrice.toDoubleOrNull() ?: 0.0
|
||||
this[SnapshotHoldingsTable.profitRate] = stock.profitRate.toDoubleOrNull() ?: 0.0
|
||||
this[SnapshotHoldingsTable.evalAmount] = stock.evalAmount.toLongOrNull() ?: 0L
|
||||
this[SnapshotHoldingsTable.isDomestic] = stock.isDomestic
|
||||
this[SnapshotHoldingsTable.isTodayEntry] = stock.isTodayEntry
|
||||
}
|
||||
}
|
||||
|
||||
println("📊 [Report] ${actualSnapshotType.name} 자산 스냅샷 저장 완료 (보유종목: ${balance.getHolldings().size}개)")
|
||||
|
||||
// 💡 4. 마감 플래그: END일 때만 리포트 생성 (MIDDLE은 데이터만 적재하고 패스)
|
||||
if (actualSnapshotType == SnapshotType.END) {
|
||||
println("🔔 [Report] 장 마감(END) 확인. 일간 매매 리포트 생성을 시작합니다.")
|
||||
generateDailyLocalReport()
|
||||
"${duration.toMillis() / 1000.0}초"
|
||||
}
|
||||
if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
|
||||
} catch (e: Exception) {
|
||||
"계산 불가"
|
||||
}
|
||||
}
|
||||
|
||||
private fun findBuyPrice(stockCode: String, currentTradeId: Int, todayDate: String): Double {
|
||||
// 1차 방어선: 당일 체결된 매수 내역 중 확정된 purchasePrice가 있는지 확인
|
||||
val recentBuyTrade = TradeHistoryTable.select {
|
||||
(TradeHistoryTable.stockCode eq stockCode) and
|
||||
(TradeHistoryTable.isBuy eq true) and
|
||||
(TradeHistoryTable.id less currentTradeId)
|
||||
}.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
|
||||
|
||||
if (recentBuyTrade != null) {
|
||||
val pPrice = recentBuyTrade[TradeHistoryTable.purchasePrice]
|
||||
if (pPrice > 0.0) return pPrice // 💡 새로 추가된 확정 단가 최우선 사용
|
||||
|
||||
// 만약 없다면 체결 내역에서 계산 (기존 방식)
|
||||
val buyExecutions = ExecutionDetailsTable.select {
|
||||
ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id]
|
||||
}.toList()
|
||||
val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
|
||||
if (buyQty > 0) {
|
||||
return buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
|
||||
}
|
||||
}
|
||||
|
||||
// 2차 방어선: 당일 아침 START 스냅샷
|
||||
val startSnapshotId = AssetSnapshotTable.select {
|
||||
(AssetSnapshotTable.date eq todayDate) and
|
||||
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
}.limit(1).singleOrNull()?.get(AssetSnapshotTable.id)
|
||||
|
||||
if (startSnapshotId != null) {
|
||||
val startAvgPrice = SnapshotHoldingsTable.select {
|
||||
(SnapshotHoldingsTable.snapshotId eq startSnapshotId) and
|
||||
(SnapshotHoldingsTable.code eq stockCode)
|
||||
}.singleOrNull()?.get(SnapshotHoldingsTable.avgPrice)
|
||||
|
||||
if (startAvgPrice != null && startAvgPrice > 0) return startAvgPrice
|
||||
}
|
||||
|
||||
// 3차 방어선: 레거시 보유 평단가
|
||||
return TradeHistoryTable.select { TradeHistoryTable.id eq currentTradeId }
|
||||
.singleOrNull()?.get(TradeHistoryTable.holdingAvgPrice) ?: 0.0
|
||||
}
|
||||
|
||||
|
||||
override fun recordTradeDecision(orderNo: String,
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
@ -330,126 +488,126 @@ object TradingReportManager : TradingReportService {
|
||||
* [4] 장 마감 로컬 리포트(HTML) 자동 생성
|
||||
*/
|
||||
override fun generateDailyLocalReport() {
|
||||
transaction(DatabaseFactory.reportDb) {
|
||||
val today = LocalDate.now().toString()
|
||||
|
||||
val startAsset = AssetSnapshotTable.select {
|
||||
(AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
}.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: 0L
|
||||
|
||||
val endAsset = AssetSnapshotTable.select {
|
||||
(AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.END.name)
|
||||
}.orderBy(AssetSnapshotTable.id to SortOrder.DESC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: startAsset
|
||||
|
||||
// 💡 [변경] isBuy == false 필터를 제거하여 금일 발생한 '모든' 주문 내역을 가져옵니다.
|
||||
val allTrades = TradeHistoryTable.select {
|
||||
(TradeHistoryTable.orderTime like "$today%")
|
||||
}.orderBy(TradeHistoryTable.orderTime to SortOrder.DESC).toList()
|
||||
|
||||
val tradeLogs = allTrades.map { row ->
|
||||
val tradeId = row[TradeHistoryTable.id]
|
||||
val stockCode = row[TradeHistoryTable.stockCode]
|
||||
val isBuy = row[TradeHistoryTable.isBuy]
|
||||
val status = row[TradeHistoryTable.status]
|
||||
val orderTimeStr = row[TradeHistoryTable.orderTime] // 예: "2024-05-20T09:10:00.123"
|
||||
|
||||
// 1. 체결 상세 정보 가져오기
|
||||
val executions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.toList()
|
||||
val execQty = executions.sumOf { it[ExecutionDetailsTable.quantity] }
|
||||
|
||||
// VWAP 평균 체결가 산출
|
||||
val avgPrice = if (execQty > 0) {
|
||||
executions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / execQty
|
||||
} else {
|
||||
row[TradeHistoryTable.currentPrice] ?: 0.0
|
||||
}
|
||||
|
||||
// 💡 2. 주문부터 마지막 체결까지 소요된 시간 계산 로직
|
||||
val lastExecTimeStr = executions.maxByOrNull { it[ExecutionDetailsTable.execTime] }?.get(ExecutionDetailsTable.execTime)
|
||||
|
||||
val timeTakenStr = if (lastExecTimeStr != null) {
|
||||
try {
|
||||
val orderTime = LocalDateTime.parse(orderTimeStr)
|
||||
val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
|
||||
val duration = Duration.between(orderTime, lastExecTime)
|
||||
|
||||
val timeString = if (duration.toMinutes() > 0) {
|
||||
"${duration.toMinutes()}분 ${duration.seconds % 60}초"
|
||||
} else {
|
||||
"${duration.toMillis() / 1000.0}초"
|
||||
}
|
||||
|
||||
// 아직 상태가 COMPLETED가 아니면 미완료 표시 (현재 상태값이 ORDERED, PARTIAL 등일 경우)
|
||||
if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
|
||||
} catch (e: Exception) {
|
||||
"계산 불가"
|
||||
}
|
||||
} else {
|
||||
"미체결 (진행중)"
|
||||
}
|
||||
|
||||
// 3. 수익률 및 수익금 계산 (매도일 때만)
|
||||
var calculatedProfitRate = 0.0
|
||||
var calculatedProfitAmount = 0L
|
||||
|
||||
if (!isBuy) {
|
||||
// 과거 매수 단가 찾기 (1순위: 이전 매수 기록, 2순위: 아침 스냅샷)
|
||||
val recentBuyTrade = TradeHistoryTable.select {
|
||||
(TradeHistoryTable.stockCode eq stockCode) and
|
||||
(TradeHistoryTable.isBuy eq true) and
|
||||
(TradeHistoryTable.id less tradeId)
|
||||
}.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
|
||||
|
||||
var avgBuyPrice = 0.0
|
||||
if (recentBuyTrade != null) {
|
||||
val buyExecutions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id] }.toList()
|
||||
val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
|
||||
if (buyQty > 0) avgBuyPrice = buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
|
||||
}
|
||||
|
||||
// 2차 시도: 오늘 아침 START 스냅샷에 기록된 보유 평단가
|
||||
if (avgBuyPrice == 0.0) {
|
||||
val startHoldings = SnapshotHoldingsTable.innerJoin(AssetSnapshotTable).select {
|
||||
(SnapshotHoldingsTable.code eq stockCode) and
|
||||
(AssetSnapshotTable.date eq today) and
|
||||
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
}.singleOrNull()
|
||||
avgBuyPrice = startHoldings?.get(SnapshotHoldingsTable.avgPrice) ?: 0.0
|
||||
}
|
||||
|
||||
// 💡 3차 시도 (궁극의 방어막): 리포팅 도입 이전부터 들고 있던 레거시 종목일 경우
|
||||
// 매도 주문 당시에 DB에 백업해두었던 증권사 보유 평단가를 그대로 사용합니다.
|
||||
if (avgBuyPrice == 0.0) {
|
||||
avgBuyPrice = row[TradeHistoryTable.holdingAvgPrice]
|
||||
}
|
||||
|
||||
// --- [C] 최종 수익률 계산 ---
|
||||
if (avgBuyPrice > 0.0) {
|
||||
calculatedProfitRate = ((avgPrice - avgBuyPrice) / avgBuyPrice) * 100
|
||||
calculatedProfitAmount = ((avgPrice - avgBuyPrice) * execQty).toLong()
|
||||
} else {
|
||||
calculatedProfitRate = 0.0
|
||||
calculatedProfitAmount = 0L
|
||||
}
|
||||
}
|
||||
|
||||
// 4. DTO 조립
|
||||
LocalReportGenerator.TradeDetailData(
|
||||
isBuy = isBuy,
|
||||
stockName = row[TradeHistoryTable.stockName],
|
||||
orderTime = orderTimeStr,
|
||||
timeTaken = timeTakenStr,
|
||||
execQty = execQty,
|
||||
avgPrice = avgPrice.toLong(),
|
||||
profitRate = calculatedProfitRate,
|
||||
profitAmount = calculatedProfitAmount,
|
||||
reason = row[TradeHistoryTable.reason],
|
||||
investmentGrade = row[TradeHistoryTable.investmentGrade],
|
||||
aiScore = row[TradeHistoryTable.aiScore]
|
||||
)
|
||||
}
|
||||
|
||||
LocalReportGenerator.generateAndOpen(startAsset, endAsset, tradeLogs)
|
||||
}
|
||||
// transaction(DatabaseFactory.reportDb) {
|
||||
// val today = LocalDate.now().toString()
|
||||
//
|
||||
// val startAsset = AssetSnapshotTable.select {
|
||||
// (AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
// }.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: 0L
|
||||
//
|
||||
// val endAsset = AssetSnapshotTable.select {
|
||||
// (AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.END.name)
|
||||
// }.orderBy(AssetSnapshotTable.id to SortOrder.DESC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: startAsset
|
||||
//
|
||||
// // 💡 [변경] isBuy == false 필터를 제거하여 금일 발생한 '모든' 주문 내역을 가져옵니다.
|
||||
// val allTrades = TradeHistoryTable.select {
|
||||
// (TradeHistoryTable.orderTime like "$today%")
|
||||
// }.orderBy(TradeHistoryTable.orderTime to SortOrder.DESC).toList()
|
||||
//
|
||||
// val tradeLogs = allTrades.map { row ->
|
||||
// val tradeId = row[TradeHistoryTable.id]
|
||||
// val stockCode = row[TradeHistoryTable.stockCode]
|
||||
// val isBuy = row[TradeHistoryTable.isBuy]
|
||||
// val status = row[TradeHistoryTable.status]
|
||||
// val orderTimeStr = row[TradeHistoryTable.orderTime] // 예: "2024-05-20T09:10:00.123"
|
||||
//
|
||||
// // 1. 체결 상세 정보 가져오기
|
||||
// val executions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.toList()
|
||||
// val execQty = executions.sumOf { it[ExecutionDetailsTable.quantity] }
|
||||
//
|
||||
// // VWAP 평균 체결가 산출
|
||||
// val avgPrice = if (execQty > 0) {
|
||||
// executions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / execQty
|
||||
// } else {
|
||||
// row[TradeHistoryTable.currentPrice] ?: 0.0
|
||||
// }
|
||||
//
|
||||
// // 💡 2. 주문부터 마지막 체결까지 소요된 시간 계산 로직
|
||||
// val lastExecTimeStr = executions.maxByOrNull { it[ExecutionDetailsTable.execTime] }?.get(ExecutionDetailsTable.execTime)
|
||||
//
|
||||
// val timeTakenStr = if (lastExecTimeStr != null) {
|
||||
// try {
|
||||
// val orderTime = LocalDateTime.parse(orderTimeStr)
|
||||
// val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
|
||||
// val duration = Duration.between(orderTime, lastExecTime)
|
||||
//
|
||||
// val timeString = if (duration.toMinutes() > 0) {
|
||||
// "${duration.toMinutes()}분 ${duration.seconds % 60}초"
|
||||
// } else {
|
||||
// "${duration.toMillis() / 1000.0}초"
|
||||
// }
|
||||
//
|
||||
// // 아직 상태가 COMPLETED가 아니면 미완료 표시 (현재 상태값이 ORDERED, PARTIAL 등일 경우)
|
||||
// if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
|
||||
// } catch (e: Exception) {
|
||||
// "계산 불가"
|
||||
// }
|
||||
// } else {
|
||||
// "미체결 (진행중)"
|
||||
// }
|
||||
//
|
||||
// // 3. 수익률 및 수익금 계산 (매도일 때만)
|
||||
// var calculatedProfitRate = 0.0
|
||||
// var calculatedProfitAmount = 0L
|
||||
//
|
||||
// if (!isBuy) {
|
||||
// // 과거 매수 단가 찾기 (1순위: 이전 매수 기록, 2순위: 아침 스냅샷)
|
||||
// val recentBuyTrade = TradeHistoryTable.select {
|
||||
// (TradeHistoryTable.stockCode eq stockCode) and
|
||||
// (TradeHistoryTable.isBuy eq true) and
|
||||
// (TradeHistoryTable.id less tradeId)
|
||||
// }.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
|
||||
//
|
||||
// var avgBuyPrice = 0.0
|
||||
// if (recentBuyTrade != null) {
|
||||
// val buyExecutions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id] }.toList()
|
||||
// val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
|
||||
// if (buyQty > 0) avgBuyPrice = buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
|
||||
// }
|
||||
//
|
||||
// // 2차 시도: 오늘 아침 START 스냅샷에 기록된 보유 평단가
|
||||
// if (avgBuyPrice == 0.0) {
|
||||
// val startHoldings = SnapshotHoldingsTable.innerJoin(AssetSnapshotTable).select {
|
||||
// (SnapshotHoldingsTable.code eq stockCode) and
|
||||
// (AssetSnapshotTable.date eq today) and
|
||||
// (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
|
||||
// }.singleOrNull()
|
||||
// avgBuyPrice = startHoldings?.get(SnapshotHoldingsTable.avgPrice) ?: 0.0
|
||||
// }
|
||||
//
|
||||
// // 💡 3차 시도 (궁극의 방어막): 리포팅 도입 이전부터 들고 있던 레거시 종목일 경우
|
||||
// // 매도 주문 당시에 DB에 백업해두었던 증권사 보유 평단가를 그대로 사용합니다.
|
||||
// if (avgBuyPrice == 0.0) {
|
||||
// avgBuyPrice = row[TradeHistoryTable.holdingAvgPrice]
|
||||
// }
|
||||
//
|
||||
// // --- [C] 최종 수익률 계산 ---
|
||||
// if (avgBuyPrice > 0.0) {
|
||||
// calculatedProfitRate = ((avgPrice - avgBuyPrice) / avgBuyPrice) * 100
|
||||
// calculatedProfitAmount = ((avgPrice - avgBuyPrice) * execQty).toLong()
|
||||
// } else {
|
||||
// calculatedProfitRate = 0.0
|
||||
// calculatedProfitAmount = 0L
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 4. DTO 조립
|
||||
// LocalReportGenerator.TradeDetailData(
|
||||
// isBuy = isBuy,
|
||||
// stockName = row[TradeHistoryTable.stockName],
|
||||
// orderTime = orderTimeStr,
|
||||
// timeTaken = timeTakenStr,
|
||||
// execQty = execQty,
|
||||
// avgPrice = avgPrice.toLong(),
|
||||
// profitRate = calculatedProfitRate,
|
||||
// profitAmount = calculatedProfitAmount,
|
||||
// reason = row[TradeHistoryTable.reason],
|
||||
// investmentGrade = row[TradeHistoryTable.investmentGrade],
|
||||
// aiScore = row[TradeHistoryTable.aiScore]
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// LocalReportGenerator.generateAndOpen(startAsset, endAsset, tradeLogs)
|
||||
// }
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package report.database
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
||||
// 💡 [신규] 설정 변경 이력 전용 테이블
|
||||
object ConfigHistoryTable : Table("config_history") {
|
||||
val id = integer("id").autoIncrement()
|
||||
@ -11,18 +12,22 @@ object ConfigHistoryTable : Table("config_history") {
|
||||
}
|
||||
|
||||
// [1] 자산 스냅샷 마스터 (설정 필드 제거)
|
||||
object AssetSnapshotTable : Table("asset_snapshots") {
|
||||
val id = integer("id").autoIncrement()
|
||||
val date = varchar("date", 10)
|
||||
object AssetSnapshotTable : IntIdTable("asset_snapshots") {
|
||||
// override val id = integer("id").autoIncrement()
|
||||
val date = varchar("date", 10)
|
||||
val snapshotType = varchar("type", 20)
|
||||
|
||||
val totalAsset = long("total_asset")
|
||||
val totalProfitRate = double("profit_rate")
|
||||
val deposit = long("deposit")
|
||||
val totalProfitRate = double("profit_rate") // 대표님이 추가하신 기존 필드
|
||||
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()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
// override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// [2] 보유 종목 상세 (UnifiedStockHolding 기준)
|
||||
@ -38,20 +43,22 @@ object SnapshotHoldingsTable : Table("snapshot_holdings") {
|
||||
val currentPrice = double("current_price")
|
||||
val profitRate = double("profit_rate")
|
||||
val evalAmount = long("eval_amount")
|
||||
val isDomestic = bool("is_domestic")
|
||||
val isTodayEntry = bool("is_today_entry") // 당일 진입 여부
|
||||
val isDomestic = bool("is_domestic").nullable()
|
||||
val isTodayEntry = bool("is_today_entry").nullable() // 당일 진입 여부
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// [3] 매매 이력 및 결정 근거 (TradingDecision + 수정된 TradeHistoryTable)
|
||||
object TradeHistoryTable : Table("trade_history") {
|
||||
val id = integer("id").autoIncrement()
|
||||
object TradeHistoryTable : IntIdTable("trade_history") {
|
||||
// val id = integer("id").autoIncrement()
|
||||
val orderNo = varchar("order_no", 50).uniqueIndex()
|
||||
val stockCode = varchar("stock_code", 20)
|
||||
val stockName = varchar("stock_name", 100)
|
||||
val orderTime = varchar("order_time", 50)
|
||||
val isBuy = bool("is_buy")
|
||||
// 💡 [핵심 신규 필드] 수익 0원 방지용: 실제 체결이 완료된 매수 평단가 박제
|
||||
val purchasePrice = double("purchase_price").default(0.0)
|
||||
val status = varchar("status", 20)
|
||||
val orderQty = integer("order_qty").default(0)
|
||||
val reason = text("reason") // AI 판단 전문
|
||||
@ -63,7 +70,7 @@ object TradeHistoryTable : Table("trade_history") {
|
||||
val technicalScore = double("technical_score").nullable()
|
||||
val investmentGrade = text("investment_grade").nullable() // 투자 등급 (S, A, B...)
|
||||
val holdingAvgPrice = double("holding_avg_price").default(0.0)
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
// override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// [4] 체결 상세 (그대로 유지)
|
||||
|
||||
@ -206,19 +206,14 @@ object AutoTradingManager {
|
||||
scope.launch {
|
||||
var basePrice = decision.currentPrice
|
||||
val tickSize = MarketUtil.getTickSize(basePrice)
|
||||
// 등급별 가이드에 따라 매수 호가 설정
|
||||
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
|
||||
var stockCode = decision.stockCode
|
||||
var stockName = decision.stockName
|
||||
val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble())
|
||||
val maxStocks = KisSession.config.getValues(ConfigIndex.MAX_HOLDING_COUNT).toInt()
|
||||
|
||||
// 💡 2. 매수 실행 전, 안전장치 통과 여부 확인
|
||||
if (!canAddNewPosition(maxStocks)) {
|
||||
// 제한에 걸렸다면, 매수 로직을 건너뛰고 매도(보유 종목 관리) 로직으로만 넘어갑니다.
|
||||
println("🚫 [안전 장치 작동] 현재 포지션이 가득 찼습니다. (최대 ${myOredsAndBalanceCodes.size}/${maxStocks}종목). 신규 매수를 일시 중단하고 매도에 집중합니다.")
|
||||
|
||||
// UI나 로그에 상태를 띄워주면 좋습니다.
|
||||
TradingLogStore.addNotice("SYSTEM", "LIMIT", "최대 보유 종목 도달로 신규 매수 일시 중단")
|
||||
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName))
|
||||
TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
|
||||
@ -227,14 +222,11 @@ object AutoTradingManager {
|
||||
|
||||
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
|
||||
.onSuccess { realOrderNo ->
|
||||
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
|
||||
println("[${investmentGrade.displayName}] 주문 성공: $realOrderNo $stockCode $orderQty $finalPrice")
|
||||
TradingLogStore.addLog(decision, "BUY", "[${investmentGrade.displayName}] 주문 성공: $realOrderNo")
|
||||
|
||||
// 손절 라인 하드코딩 (필요시 Config로 빼는 것 권장)
|
||||
val sRate = -1.5
|
||||
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||
// 최소 보장 수익률(전역 설정)과 요청 수익률 중 큰 값 선택 후 세금 더하기
|
||||
val effectiveProfitRate = (profitRate1 ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + tax
|
||||
|
||||
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
|
||||
@ -301,17 +293,18 @@ object AutoTradingManager {
|
||||
|
||||
if (dbItem != null && execData != null && execData.isFilled) {
|
||||
if (dbItem.status == TradeStatus.PENDING_BUY) {
|
||||
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
|
||||
// ✅ 1. 진짜 사온 가격 (실제 매수 체결가)
|
||||
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 finalProfitRate = maxOf(dbItem.profitRate, absoluteMinRate)
|
||||
|
||||
// 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
|
||||
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
||||
|
||||
TradingReportManager.updateExecution(orderNo,finalTargetPrice,dbItem.quantity)
|
||||
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
|
||||
println("🎯 [매수 확정] ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가 설정: ${finalTargetPrice.toInt()}")
|
||||
|
||||
KisTradeService.postOrder(
|
||||
stockCode = dbItem.code,
|
||||
@ -319,29 +312,33 @@ object AutoTradingManager {
|
||||
price = finalTargetPrice.toLong().toString(),
|
||||
isBuy = false
|
||||
).onSuccess { newSellOrderNo ->
|
||||
// 익절가 업데이트 및 상태 변경
|
||||
// 💡 [매도 주문 기록] 이제 팔기 시작했다는 의사결정을 리포트에 남깁니다.
|
||||
TradingReportManager.recordTradeDecision(
|
||||
orderNo = newSellOrderNo,
|
||||
stockCode = dbItem.code,
|
||||
stockName = dbItem.name,
|
||||
isBuy = false,
|
||||
orderQty = dbItem.quantity,
|
||||
reason = "🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)", // AI 이유
|
||||
decision = null // AI 객체 통째로 전달
|
||||
reason = "🎯 목표 수익률 ${String.format("%.2f", finalProfitRate)}% 도달을 위한 익절 주문",
|
||||
holdingAvgPrice = actualBuyPrice, // 👈 여기서 매수단가를 넘겨줘야 매도 리포트가 정확해집니다!
|
||||
decision = null
|
||||
)
|
||||
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)
|
||||
}.onFailure {
|
||||
println("❌ 익절 주문 실패: ${it.message}")
|
||||
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","❌ 익절 주문 실패: ${it.message}")
|
||||
}
|
||||
} else if (dbItem.status == TradeStatus.SELLING) {
|
||||
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
|
||||
myOredsAndBalanceCodes.remove(dbItem.code)
|
||||
// ✅ 2. 매도 완료 시점 (실제 매도 체결가)
|
||||
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)
|
||||
executionCache.remove(orderNo)
|
||||
}
|
||||
@ -375,7 +372,7 @@ object AutoTradingManager {
|
||||
}
|
||||
|
||||
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
|
||||
balance.getHolldings().forEach { holding ->
|
||||
balance.getHoldings().forEach { holding ->
|
||||
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
|
||||
println("❌ 차단 처리된 주식 : ${holding.name}")
|
||||
TradingLogStore.addAnalyzer(
|
||||
@ -437,6 +434,20 @@ object AutoTradingManager {
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -453,7 +464,7 @@ object AutoTradingManager {
|
||||
|
||||
|
||||
println("resumePendingSellOrders")
|
||||
balance.getHolldings().forEach { holding ->
|
||||
balance.getHoldings().forEach { holding ->
|
||||
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
|
||||
println("❌ 차단 처리된 주식 : ${holding.name}")
|
||||
TradingLogStore.addAnalyzer(
|
||||
@ -487,6 +498,19 @@ object AutoTradingManager {
|
||||
"SELL",
|
||||
"🎊 보유 주식[예상수익 : ${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 {
|
||||
TradingLogStore.addSellLog(
|
||||
holding.code,
|
||||
@ -496,7 +520,21 @@ object AutoTradingManager {
|
||||
)
|
||||
}
|
||||
} 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 호출 부하 방지
|
||||
}
|
||||
@ -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 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 lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다)
|
||||
if (profit <= lossThreshold) {
|
||||
@ -532,10 +570,10 @@ object AutoTradingManager {
|
||||
// 🟢 [추매 타점] 볼린저 하단 터치(1.05배 이내) + RSI 과매도(35 이하) 구간
|
||||
if (lowerBand > 0 && currentPrice <= lowerBand * 1.05 && rsiDaily < 35.0) {
|
||||
advice = "📉 [추매 권장] 볼린저 밴드 하단 터치 및 RSI 과매도(${"%.1f".format(rsiDaily)}). 기술적 반등 확률이 매우 높은 통계적 바닥권입니다. (물타기 고려)"
|
||||
TradingLogStore.addAnalyzer(
|
||||
TradingLogStore.addNotice(
|
||||
"보유주식[${holding.name}]",
|
||||
holding.code,
|
||||
"수익률($profit%) -> $advice", true
|
||||
"수익률($profit%) -> $advice"
|
||||
)
|
||||
}
|
||||
// 🔴 [손절 타점] 추세가 완전히 깨졌는데, 바닥(볼린저 하단)까지 한참 남았을 때
|
||||
@ -622,6 +660,7 @@ object AutoTradingManager {
|
||||
val H15M30 = LocalTime.of(15, 30)
|
||||
val H16 = LocalTime.of(16, 0)
|
||||
val H18 = LocalTime.of(18, 0)
|
||||
val H20 = LocalTime.of(20, 0)
|
||||
val H08M00 = LocalTime.of(8, 0)
|
||||
val H08M45 = LocalTime.of(8, 45)
|
||||
val H07M50 = LocalTime.of(7, 50)
|
||||
@ -634,10 +673,10 @@ object AutoTradingManager {
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
|
||||
when {
|
||||
now.isAfter(H18) || now.isBefore(H07M50) -> {
|
||||
now.isAfter(H20) || now.isBefore(H07M50) -> {
|
||||
prepareMarketOpen(now)
|
||||
}
|
||||
now.isBefore(H18) && now.isAfter(H08M00) -> {
|
||||
now.isBefore(H20) && now.isAfter(H08M00) -> {
|
||||
waitTime = 0.2
|
||||
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
|
||||
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
|
||||
@ -651,7 +690,7 @@ object AutoTradingManager {
|
||||
}
|
||||
withTimeout(CYCLE_TIMEOUT) {
|
||||
println("⏱️ [Cycle Start] ${LocalTime.now()}")
|
||||
if (now.isAfter(H18)) {
|
||||
if (now.isAfter(H20)) {
|
||||
executeClosingLiquidation(KisTradeService)
|
||||
} else {
|
||||
executeMarketLoop()
|
||||
@ -674,7 +713,7 @@ object AutoTradingManager {
|
||||
}
|
||||
|
||||
suspend fun prepareMarketOpen(now : LocalTime) {
|
||||
if (now.isAfter(H18) || now.isBefore(H07M50)) {
|
||||
if (now.isAfter(H20) || now.isBefore(H07M50)) {
|
||||
println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.")
|
||||
onMarketClosed?.invoke()
|
||||
RagService.clearDailyCache()
|
||||
@ -717,7 +756,12 @@ object AutoTradingManager {
|
||||
if (isMorning) {
|
||||
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
|
||||
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) }
|
||||
@ -729,7 +773,7 @@ object AutoTradingManager {
|
||||
myOredsAndBalanceCodes.clear()
|
||||
checkBalance()
|
||||
val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
|
||||
val myHoldings = currentBalance?.getHolldings()?.map {
|
||||
val myHoldings = currentBalance?.getHoldings()?.map {
|
||||
myOredsAndBalanceCodes.add(it.code)
|
||||
it.code }?.toSet() ?: emptySet()
|
||||
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
|
||||
@ -810,7 +854,7 @@ object AutoTradingManager {
|
||||
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) {
|
||||
TradingLogStore.addAnalyzer(
|
||||
" - ",
|
||||
@ -819,7 +863,7 @@ object AutoTradingManager {
|
||||
true
|
||||
)
|
||||
var list = mutableListOf<String>("X")
|
||||
if (now.hour != 8) {
|
||||
if (now.hour != 8 && now.hour < 18) {
|
||||
list.add("Y")
|
||||
}
|
||||
list.forEach { code ->
|
||||
@ -901,22 +945,15 @@ object AutoTradingManager {
|
||||
}
|
||||
|
||||
private suspend fun fetchCandidates(tradeService: KisTradeService): List<RankingStock> = coroutineScope {
|
||||
|
||||
|
||||
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.VOLUME0, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.VOLUME1, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.VOLUME4, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.RISE, 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.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.FINANCE, true).getOrDefault(emptyList()) },
|
||||
async { tradeService.fetchMarketRanking(RankingType.MARKET_VALUE, true).getOrDefault(emptyList()) },
|
||||
@ -948,7 +985,7 @@ object AutoTradingManager {
|
||||
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
|
||||
val activeTrades = DatabaseFactory.findAllMonitoringTrades()
|
||||
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
|
||||
val realHoldings = balanceResult?.getHolldings()?.associateBy { it.code } ?: emptyMap()
|
||||
val realHoldings = balanceResult?.getHoldings()?.associateBy { it.code } ?: emptyMap()
|
||||
|
||||
activeTrades.forEach { trade ->
|
||||
try {
|
||||
@ -988,16 +1025,6 @@ object AutoTradingManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun checkAndRestart() {
|
||||
if (!isRunning()) {
|
||||
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
|
||||
startAutoDiscoveryLoop()
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -219,7 +219,6 @@ fun TradingDecisionLog() {
|
||||
// ==========================================
|
||||
// 1️⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3)
|
||||
// ==========================================
|
||||
var firstSet = mutableSetOf<ConfigIndex>()
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지
|
||||
Text(
|
||||
@ -247,13 +246,7 @@ fun TradingDecisionLog() {
|
||||
if (configKey.label.contains("PROFIT")) {
|
||||
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)
|
||||
DatabaseFactory.saveConfig(KisSession.config)
|
||||
}
|
||||
|
||||
var text = if (configKey.label.contains("PROFIT")) {
|
||||
@ -295,8 +288,10 @@ fun TradingDecisionLog() {
|
||||
item(span = { GridItemSpan(3) }) {
|
||||
SettingSwitchField(
|
||||
label = "자동 익절 활성화",
|
||||
initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) == 1.0,
|
||||
onCheckedChange = { KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0) },
|
||||
initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) > 0.0,
|
||||
onCheckedChange = {
|
||||
KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0)
|
||||
},
|
||||
helperText = "목표 수익률 도달 시 기계적 익절"
|
||||
)
|
||||
}
|
||||
@ -304,7 +299,7 @@ fun TradingDecisionLog() {
|
||||
item(span = { GridItemSpan(3) }) {
|
||||
SettingSwitchField(
|
||||
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) },
|
||||
helperText = "손실 방어선 도달 시 기계적 손절"
|
||||
)
|
||||
@ -389,12 +384,6 @@ fun TradingDecisionLog() {
|
||||
var oldValue = KisSession.config.getValues(configKey)
|
||||
var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||
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 업데이트 로직 (기존과 동일)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user