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.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,

View File

@ -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()}

View File

@ -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,

View File

@ -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)
}
}
/**

View File

@ -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())
}
}
}

View File

@ -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)
// }
}
}

View File

@ -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] 체결 상세 (그대로 유지)

View File

@ -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 {
}
}
}

View File

@ -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 업데이트 로직 (기존과 동일)