리포팅 테스트

This commit is contained in:
lunaticbum 2026-04-16 15:48:23 +09:00
parent 68a7a05961
commit ed12d07bc2
9 changed files with 890 additions and 162 deletions

View File

@ -20,6 +20,7 @@ import ConfigTable.max_count
import ConfigTable.max_holding_count import ConfigTable.max_holding_count
import ConfigTable.stop_Loss import ConfigTable.stop_Loss
import ConfigTable.take_profit import ConfigTable.take_profit
import DatabaseFactory.mainDb
import Defines.DETAILLOG import Defines.DETAILLOG
import Defines.EMBEDDING_PORT import Defines.EMBEDDING_PORT
import Defines.LLM_PORT import Defines.LLM_PORT
@ -232,7 +233,7 @@ fun main() = application {
// 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입) // 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
DatabaseFactory.init() DatabaseFactory.init()
transaction { transaction(mainDb) {
ConfigTable.selectAll().lastOrNull()?.let { ConfigTable.selectAll().lastOrNull()?.let {
KisSession.config = AppConfig( KisSession.config = AppConfig(
realAppKey = it[ConfigTable.realAppKey], realAppKey = it[ConfigTable.realAppKey],

View File

@ -1,11 +1,17 @@
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import model.AppConfig import model.AppConfig
import network.TradingDecision import model.TradingDecision
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.javatime.datetime
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import report.TradingReportManager
import report.TradingReportService
import report.database.AssetSnapshotTable
import report.database.ExecutionDetailsTable
import report.database.SnapshotHoldingsTable
import report.database.TradeHistoryTable
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
@ -116,21 +122,40 @@ object HolidayTable : Table("holiday_cache") {
} }
object DatabaseFactory { object DatabaseFactory {
val reporter: TradingReportService get() = TradingReportManager
lateinit var mainDb: Database
lateinit var reportDb: Database
fun init() { fun init() {
val dbPath = File("db/autotrade_db").absolutePath mainDb = Database.connect(
Database.connect( "jdbc:h2:${File("db/autotrade_db").absolutePath};DB_CLOSE_DELAY=-1;",
"jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;",
driver = "org.h2.Driver" driver = "org.h2.Driver"
) )
transaction { reportDb = Database.connect(
"jdbc:h2:${File("db/trade_report").absolutePath};DB_CLOSE_DELAY=-1;",
driver = "org.h2.Driver"
)
transaction(reportDb) {
SchemaUtils.createMissingTablesAndColumns(
AssetSnapshotTable,
SnapshotHoldingsTable,
TradeHistoryTable,
ExecutionDetailsTable
)
}
transaction(mainDb) {
// 테이블 생성 (AutoTradeTable 포함) // 테이블 생성 (AutoTradeTable 포함)
SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,HolidayTable) SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,HolidayTable)
} }
} }
fun saveHoliday(date: String, holiday: Boolean) = transaction { fun saveHoliday(date: String, holiday: Boolean) = transaction(mainDb) {
HolidayTable.replace { HolidayTable.replace {
it[bassDt] = date it[bassDt] = date
it[isHoliday] = holiday it[isHoliday] = holiday
@ -138,7 +163,7 @@ object DatabaseFactory {
} }
// 특정 날짜의 휴장 여부 조회 // 특정 날짜의 휴장 여부 조회
fun getHoliday(date: String): Boolean? = transaction { fun getHoliday(date: String): Boolean? = transaction(mainDb) {
HolidayTable.select { HolidayTable.bassDt eq date } HolidayTable.select { HolidayTable.bassDt eq date }
.map { it[HolidayTable.isHoliday] } .map { it[HolidayTable.isHoliday] }
.singleOrNull() .singleOrNull()
@ -147,7 +172,7 @@ object DatabaseFactory {
/** /**
* 새로운 자동매매 등록 (주로 PENDING_BUY 상태로 시작) * 새로운 자동매매 등록 (주로 PENDING_BUY 상태로 시작)
*/ */
fun saveAutoTrade(item: AutoTradeItem) = transaction { fun saveAutoTrade(item: AutoTradeItem) = transaction(mainDb) {
AutoTradeTable.insert { AutoTradeTable.insert {
it[stockCode] = item.code it[stockCode] = item.code
it[stockName] = item.name it[stockName] = item.name
@ -165,7 +190,7 @@ object DatabaseFactory {
/** /**
* 상태 변경 가격 업데이트 (: PENDING_BUY -> MONITORING) * 상태 변경 가격 업데이트 (: PENDING_BUY -> MONITORING)
*/ */
fun updateAutoTrade(item: AutoTradeItem) = transaction { fun updateAutoTrade(item: AutoTradeItem) = transaction(mainDb) {
val id = item.id ?: return@transaction val id = item.id ?: return@transaction
AutoTradeTable.update({ AutoTradeTable.id eq id }) { AutoTradeTable.update({ AutoTradeTable.id eq id }) {
it[targetPrice] = item.targetPrice it[targetPrice] = item.targetPrice
@ -178,7 +203,7 @@ object DatabaseFactory {
/** /**
* 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용) * 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용)
*/ */
fun getActiveAutoTrades(): List<AutoTradeItem> = transaction { fun getActiveAutoTrades(): List<AutoTradeItem> = transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.select {
AutoTradeTable.status inList listOf("MONITORING", "SELLING", "PENDING_BUY") AutoTradeTable.status inList listOf("MONITORING", "SELLING", "PENDING_BUY")
}.map { mapToAutoTradeItem(it) } }.map { mapToAutoTradeItem(it) }
@ -187,18 +212,18 @@ object DatabaseFactory {
/** /**
* 종목코드로 현재 감시 중인 설정이 있는지 확인 (UI 체크박스 상태용) * 종목코드로 현재 감시 중인 설정이 있는지 확인 (UI 체크박스 상태용)
*/ */
fun findConfigByCode(code: String): AutoTradeItem? = transaction { fun findConfigByCode(code: String): AutoTradeItem? = transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.select {
(AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING") (AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING")
}.lastOrNull()?.let { mapToAutoTradeItem(it) } }.lastOrNull()?.let { mapToAutoTradeItem(it) }
} }
fun deleteAutoTrade(id: Int) = transaction { fun deleteAutoTrade(id: Int) = transaction(mainDb) {
AutoTradeTable.deleteWhere { AutoTradeTable.id eq id } AutoTradeTable.deleteWhere { AutoTradeTable.id eq id }
} }
fun findAllPendingBuyCodes(): Set<String> { fun findAllPendingBuyCodes(): Set<String> {
return transaction { return transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.select {
(AutoTradeTable.status eq "PENDING_BUY") or (AutoTradeTable.status eq "ORDERED") (AutoTradeTable.status eq "PENDING_BUY") or (AutoTradeTable.status eq "ORDERED")
}.map { it[AutoTradeTable.stockCode] }.toSet() }.map { it[AutoTradeTable.stockCode] }.toSet()
@ -206,7 +231,7 @@ object DatabaseFactory {
} }
fun findAllMonitoringTrades(): List<AutoTradeItem> { fun findAllMonitoringTrades(): List<AutoTradeItem> {
return transaction { return transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.select {
AutoTradeTable.status neq "COMPLETED" AutoTradeTable.status neq "COMPLETED"
}.map { mapToAutoTradeItem(it) } }.map { mapToAutoTradeItem(it) }
@ -230,7 +255,7 @@ object DatabaseFactory {
// --- 기존 설정 및 로그 관련 함수 --- // --- 기존 설정 및 로그 관련 함수 ---
fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) { fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) {
transaction { transaction(mainDb) {
TradeLogTable.insert { TradeLogTable.insert {
it[stockCode] = code it[stockCode] = code
it[stockName] = name it[stockName] = name
@ -243,7 +268,7 @@ object DatabaseFactory {
} }
} }
fun findConfigByAccount(accountNo: String): AppConfig? = transaction { fun findConfigByAccount(accountNo: String): AppConfig? = transaction(mainDb) {
ConfigTable.select { ConfigTable.select {
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo) (ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
}.lastOrNull()?.let { }.lastOrNull()?.let {
@ -287,8 +312,8 @@ object DatabaseFactory {
stop_Loss = it[ConfigTable.stop_Loss], stop_Loss = it[ConfigTable.stop_Loss],
take_profit = it[ConfigTable.take_profit], take_profit = it[ConfigTable.take_profit],
loss_min = it[ConfigTable.loss_minrate], loss_min = it[ConfigTable.loss_minrate],
loss_max = it[ConfigTable.loss_maxrate], loss_max = it[ConfigTable.loss_maxrate],
loss_money = it[ConfigTable.loss_max_money], loss_money = it[ConfigTable.loss_max_money],
MAX_COUNT = it[ConfigTable.max_count], MAX_COUNT = it[ConfigTable.max_count],
max_holding_count = it[ConfigTable.max_holding_count], max_holding_count = it[ConfigTable.max_holding_count],
) )
@ -296,7 +321,7 @@ object DatabaseFactory {
} }
fun saveConfig(config: AppConfig) { fun saveConfig(config: AppConfig) {
transaction { transaction(mainDb) {
ConfigTable.deleteAll() ConfigTable.deleteAll()
ConfigTable.insert { ConfigTable.insert {
it[realAppKey] = config.realAppKey it[realAppKey] = config.realAppKey
@ -346,7 +371,7 @@ object DatabaseFactory {
} }
} }
fun saveOrUpdate(item: AutoTradeItem) = transaction { fun saveOrUpdate(item: AutoTradeItem) = transaction(mainDb) {
val existing = AutoTradeTable.select { AutoTradeTable.orderNo eq item.orderNo }.firstOrNull() val existing = AutoTradeTable.select { AutoTradeTable.orderNo eq item.orderNo }.firstOrNull()
if (existing == null) { if (existing == null) {
AutoTradeTable.insert { AutoTradeTable.insert {
@ -371,7 +396,7 @@ object DatabaseFactory {
/** /**
* 주문번호로 항목 조회 (가장 핵심적인 식별자) * 주문번호로 항목 조회 (가장 핵심적인 식별자)
*/ */
fun findByOrderNo(orderNo: String): AutoTradeItem? = transaction { fun findByOrderNo(orderNo: String): AutoTradeItem? = transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.orderNo eq orderNo } AutoTradeTable.select { AutoTradeTable.orderNo eq orderNo }
.map { mapToAutoTradeItem(it) } .map { mapToAutoTradeItem(it) }
.singleOrNull() .singleOrNull()
@ -380,7 +405,7 @@ object DatabaseFactory {
/** /**
* 서버 동기화: DB에는 PENDING_BUY/MONITORING인데 서버 미체결 내역에 없는 경우 EXPIRED로 변경 * 서버 동기화: DB에는 PENDING_BUY/MONITORING인데 서버 미체결 내역에 없는 경우 EXPIRED로 변경
*/ */
fun syncWithServer(serverOrderNos: List<String>) = transaction { fun syncWithServer(serverOrderNos: List<String>) = transaction(mainDb) {
AutoTradeTable.update({ AutoTradeTable.update({
(AutoTradeTable.status inList listOf(TradeStatus.PENDING_BUY, TradeStatus.MONITORING)) and (AutoTradeTable.status inList listOf(TradeStatus.PENDING_BUY, TradeStatus.MONITORING)) and
(AutoTradeTable.orderNo notInList serverOrderNos) (AutoTradeTable.orderNo notInList serverOrderNos)
@ -392,7 +417,7 @@ object DatabaseFactory {
/** /**
* 상태 업데이트 주문번호 갱신 (: 매수체결 신규 익절 주문번호로 교체) * 상태 업데이트 주문번호 갱신 (: 매수체결 신규 익절 주문번호로 교체)
*/ */
fun updateStatusAndOrderNo(id: Int, newStatus: String, newOrderNo: String? = null) = transaction { fun updateStatusAndOrderNo(id: Int, newStatus: String, newOrderNo: String? = null) = transaction(mainDb) {
AutoTradeTable.update({ AutoTradeTable.id eq id }) { AutoTradeTable.update({ AutoTradeTable.id eq id }) {
it[status] = newStatus it[status] = newStatus
if (newOrderNo != null) it[orderNo] = newOrderNo if (newOrderNo != null) it[orderNo] = newOrderNo
@ -402,7 +427,7 @@ object DatabaseFactory {
/** /**
* 감시 중인 모든 종목 리스트 (Status별 필터링 용이하게 수정) * 감시 중인 모든 종목 리스트 (Status별 필터링 용이하게 수정)
*/ */
fun getAutoTradesByStatus(statusList: List<String>): List<AutoTradeItem> = transaction { fun getAutoTradesByStatus(statusList: List<String>): List<AutoTradeItem> = transaction(mainDb) {
AutoTradeTable.select { AutoTradeTable.status inList statusList } AutoTradeTable.select { AutoTradeTable.status inList statusList }
.map { mapToAutoTradeItem(it) } .map { mapToAutoTradeItem(it) }
} }

View File

@ -164,8 +164,10 @@ data class UnifiedBalance(
val totalAsset: String, // 총 평가자산 val totalAsset: String, // 총 평가자산
val totalProfitRate: String, // 총 수익률 val totalProfitRate: String, // 총 수익률
val deposit: String, val deposit: String,
val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트 private val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트
) ) {
fun getHolldings() = holdings.filter { it.quantity.toInt() > 0 }
}
@Serializable @Serializable

View File

@ -1,6 +1,9 @@
package model package model
import analyzer.ScalpingSignalModel
import analyzer.TechnicalAnalyzer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import service.InvestmentGrade
@Serializable @Serializable
data class RealTimeTrade( data class RealTimeTrade(
@ -12,4 +15,88 @@ data class RealTimeTrade(
val type: TradeType // 매수체결(빨강) / 매도체결(파랑) val type: TradeType // 매수체결(빨강) / 매도체결(파랑)
) )
enum class TradeType { BUY, SELL, NEUTRAL } enum class TradeType { BUY, SELL, NEUTRAL }
@Serializable
class TradingDecision {
var corpName : String = ""
var stockName : String = ""
var ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
var shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
var midTermScore: Double = 0.0 // 중기 (주봉/재무)
var longTermScore: Double = 0.0
// [추가] 화면 전환용 종목명
var currentPrice: Double = 0.0
var stockCode: String = ""
var decision: String? = null
var reason: String? = null
var confidence: Double = 0.0
var newsScore : Double = 0.0
var systemScore : Double = 0.0
var financialScore : Double = 0.0
var technicalScore : Double = 0.0
var investmentGrade : InvestmentGrade? = null
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
var analyzer : TechnicalAnalyzer? = null
var signalModel : ScalpingSignalModel? = null
fun shortPossible() =
listOf<Double>(ultraShortScore,
shortTermScore).average()
fun profitPossible() =
listOf<Double>(ultraShortScore,
shortTermScore,
midTermScore,
longTermScore).average()
fun safePossible() =
listOf<Double>(
midTermScore,
longTermScore).average()
fun summary() : String{
return """
$corpName[$stockName]
수익실현 가능성 : ${profitPossible()}
investmentGrade:${investmentGrade!!.name}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
systemScore :$systemScore
technicalScore: $technicalScore
financialScore: $financialScore
newsScore: $newsScore
decision: $decision
reason: $reason
""".trimIndent()
}
override fun toString(): String {
return """
$corpName($stockName)
수익실현 가능성 : ${profitPossible()}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
decision: $decision
investmentGrade:${investmentGrade!!.name}
reason: $reason
confidence: $confidence
기술 분석: $techSummary
뉴스 점수: $newsScore
""".trimIndent()
}
}
//재무재표: $financialData
//뉴스: $newsContext

View File

@ -41,6 +41,7 @@ import kotlinx.serialization.json.putJsonObject
import model.ConfigIndex import model.ConfigIndex
import model.KisSession import model.KisSession
import model.RankingStock import model.RankingStock
import model.TradingDecision
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@ -676,84 +677,3 @@ object RagService {
} }
@Serializable
class TradingDecision {
var corpName : String = ""
var stockName : String = ""
var ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
var shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
var midTermScore: Double = 0.0 // 중기 (주봉/재무)
var longTermScore: Double = 0.0
// [추가] 화면 전환용 종목명
var currentPrice: Double = 0.0
var stockCode: String = ""
var decision: String? = null
var reason: String? = null
var confidence: Double = 0.0
var newsScore : Double = 0.0
var systemScore : Double = 0.0
var financialScore : Double = 0.0
var technicalScore : Double = 0.0
var investmentGrade : InvestmentGrade? = null
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
var analyzer : TechnicalAnalyzer? = null
var signalModel : ScalpingSignalModel? = null
fun shortPossible() =
listOf<Double>(ultraShortScore,
shortTermScore).average()
fun profitPossible() =
listOf<Double>(ultraShortScore,
shortTermScore,
midTermScore,
longTermScore).average()
fun safePossible() =
listOf<Double>(
midTermScore,
longTermScore).average()
fun summary() : String{
return """
$corpName[$stockName]
수익실현 가능성 : ${profitPossible()}
investmentGrade:${investmentGrade!!.name}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
systemScore :$systemScore
technicalScore: $technicalScore
financialScore: $financialScore
newsScore: $newsScore
decision: $decision
reason: $reason
""".trimIndent()
}
override fun toString(): String {
return """
$corpName($stockName)
수익실현 가능성 : ${profitPossible()}
ultraShortScore :$ultraShortScore
shortTermScore :$shortTermScore
midTermScore :$midTermScore
longTermScore :$longTermScore
decision: $decision
investmentGrade:${investmentGrade!!.name}
reason: $reason
confidence: $confidence
기술 분석: $techSummary
뉴스 점수: $newsScore
""".trimIndent()
}
}
//재무재표: $financialData
//뉴스: $newsContext

View File

@ -0,0 +1,166 @@
package report
import java.awt.Desktop
import java.io.File
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
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?
)
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
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>"
// 시간 포맷팅
val orderTimeParsed = LocalDateTime.parse(trade.orderTime).format(timeFormatter)
// 수익률/수익금 처리 (매수일 경우 표시 안 함)
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)}"
// 상태/시간 표시 처리
val timeStatusHtml = if (trade.timeTaken.contains("미완료")) {
"<span style='color: #FF9500; font-weight: bold;'>${trade.timeTaken}</span>"
} else {
"<span style='color: #888;'>${trade.timeTaken}</span>"
}
val gradeHtml = trade.investmentGrade?.let { "<span class='badge grade-$it'>$it</span>" } ?: ""
"""
<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()
}
}
// 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()
val directory = File("reports")
if (!directory.exists()) directory.mkdirs()
val reportFile = File(directory, "ATRADE_Report_$today.html")
try {
reportFile.writeText(htmlTemplate, Charsets.UTF_8)
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(reportFile.toURI())
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@ -0,0 +1,455 @@
package report
import io.ktor.utils.io.core.String
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import report.database.*
import model.*
import service.InvestmentGrade
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
enum class SnapshotType { START, END, MIDDLE }
@Serializable
data class ConfigSnapshot(
val targetProfit: Double, // 목표 손절 기준울
val stopLoss: Double, // 손절 기준율
val maxBudget: Double, // 최대 투자 금액
val guideScore: Double, // 최소 기준 점수
val descMinMax: String, // 주식 금액 필터링 최소,최대 금액
val grades : String, // 판장 등급별(GRADE5~GRADE1) 호가처리,세금 (0.33) + 손절((기준율 * 등급별 비율) ,최대 투자 금액(maxBudget * 등급 비율)
)
interface TradingReportService {
fun recordAssetSnapshot(type: SnapshotType, balance: UnifiedBalance, remark: String? = null)
// 💡 [신규] 설정값이 변경될 때 호출할 함수
fun recordConfigChange()
fun closePositionCycle(stockCode: String)
fun recordTradeDecision(orderNo: String,
stockCode: String,
stockName: String,
isBuy: Boolean,
orderQty: Int,
reason: String,
holdingAvgPrice: Double = 0.0,
decision: TradingDecision? = null)
fun updateExecution(orderNo: String, execPrice: Double, execQty: Int, execTime: String? = null)
fun generateDailyLocalReport()
}
object TradingReportManager : TradingReportService {
// 💡 [핵심] 당일 재진입(단타) 포지션 꼬임 방지용 상태 관리 메모리 맵
// Key: 종목코드, Value: 현재 활성화된 Position ID
private val activePositions = mutableMapOf<String, String>()
override fun recordConfigChange() {
transaction(DatabaseFactory.reportDb) {
val now = LocalDateTime.now()
// 1. 현재 설정값을 JSON으로 묶기 (대표님이 정리하신 코드 그대로 사용)
var buffer = StringBuffer()
arrayOf(
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND, InvestmentGrade.LEVEL_2_HIGH_RISK, InvestmentGrade.LEVEL_1_SPECULATIVE
).forEach { grade ->
buffer.appendLine("${grade.name}")
.appendLine(" 매수 목표 : -${KisSession.config.getValues(grade.buyGuide)}호가, 목표 수익:${KisSession.config.getValues(ConfigIndex.TAX_INDEX) + (KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))}, 최대 투자:${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * KisSession.config.getValues(grade.allocationRate)}")
}
val currentConfigJson = Json.encodeToString(
ConfigSnapshot(
targetProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) ?: 0.0,
stopLoss = KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE) ?: 0.0,
maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) ?: 0.0,
guideScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) ?: 0.0,
descMinMax = "종목 금액 필터 MIN:${KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) ?: 0.0} ~ MAX:${KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) ?: 0.0}",
grades = buffer.toString()
)
)
// 2. DB에서 가장 최근에 저장된 설정 이력 가져오기
val lastConfigRecord = ConfigHistoryTable.selectAll()
.orderBy(ConfigHistoryTable.id to SortOrder.DESC)
.limit(1)
.singleOrNull()
if (lastConfigRecord != null) {
val lastUpdatedStr = lastConfigRecord[ConfigHistoryTable.updatedAt]
val lastUpdatedTime = LocalDateTime.parse(lastUpdatedStr)
// 3. 마지막 수정 시간과 현재 시간의 차이 계산
val minutesDiff = java.time.Duration.between(lastUpdatedTime, now).toMinutes()
if (minutesDiff <= 10) {
// 💡 [핵심] 10분 이내의 변경이면 새로운 row를 만들지 않고 기존 row 덮어쓰기 (Update)
ConfigHistoryTable.update({ ConfigHistoryTable.id eq lastConfigRecord[ConfigHistoryTable.id] }) {
it[updatedAt] = now.toString() // 시간은 최신으로 갱신
it[configJson] = currentConfigJson
}
println("⚙️ [Report] 설정 연속 변경 감지 (10분 이내). 기존 이력을 덮어씁니다.")
return@transaction
}
}
// 4. 10분이 지났거나, 아예 최초 저장이라면 새로운 row 생성 (Insert)
ConfigHistoryTable.insert {
it[updatedAt] = now.toString()
it[configJson] = currentConfigJson
}
println("⚙️ [Report] 새로운 설정 변경 이력이 저장되었습니다.")
}
}
/**
* [1] 자산 현황 보유 종목 스냅샷 저장
* isClose 파라미터가 true로 들어오면 모든 기록을 마치고 자동으로 일간 리포트를 생성합니다.
*/
override fun recordAssetSnapshot(
type: SnapshotType,
balance: UnifiedBalance,
remark: String?
) {
transaction(DatabaseFactory.reportDb) {
val todayDate = LocalDate.now().toString()
// 💡 [핵심 로직] 스냅샷 타입 자동 결정
val actualSnapshotType = if (type == SnapshotType.END) {
SnapshotType.END
} else {
// 오늘 날짜로 기록된 START 스냅샷이 있는지 검사
val hasStartToday = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq todayDate) and
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.limit(1).count() > 0
// 없으면 START, 이미 있으면 MIDDLE로 지정
if (hasStartToday) SnapshotType.MIDDLE else SnapshotType.START
}
// 1. 당시의 주요 설정값 백업 (기존 대표님 코드 유지)
var buffer = StringBuffer()
arrayOf(
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND, InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND,
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND, InvestmentGrade.LEVEL_2_HIGH_RISK, InvestmentGrade.LEVEL_1_SPECULATIVE
).forEach { grade ->
buffer.appendLine("${grade.name}")
.appendLine(" 매수 목표 : -${KisSession.config.getValues(grade.buyGuide)}호가, 목표 수익:${KisSession.config.getValues(ConfigIndex.TAX_INDEX) + (KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) * KisSession.config.getValues(grade.profitGuide))}, 최대 투자:${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * KisSession.config.getValues(grade.allocationRate)}")
}
val configJson = Json.encodeToString(
ConfigSnapshot(
targetProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) ?: 0.0,
stopLoss = KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE) ?: 0.0,
maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) ?: 0.0,
guideScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) ?: 0.0,
descMinMax = "종목 금액 필터 MIN:${KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX) ?: 0.0} ~ MAX:${KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX) ?: 0.0}",
grades = buffer.toString()
)
)
// 2. 자산 마스터 데이터 저장 (실제 결정된 actualSnapshotType 사용)
val snapshotId = AssetSnapshotTable.insert {
it[date] = todayDate
it[snapshotType] = actualSnapshotType.name
it[totalAsset] = balance.totalAsset.toLongOrNull() ?: 0L
it[totalProfitRate] = balance.totalProfitRate.toDoubleOrNull() ?: 0.0
it[deposit] = balance.deposit.toLongOrNull() ?: 0L
it[appliedConfigJson] = configJson
it[this.remark] = remark ?: if (actualSnapshotType == SnapshotType.MIDDLE) "장중 상태 기록" else ""
}[AssetSnapshotTable.id]
// 3. 보유 종목 리스트 일괄 저장 (Batch Insert)
if (balance.getHolldings().isNotEmpty()) {
SnapshotHoldingsTable.batchInsert(balance.getHolldings()) { stock ->
this[SnapshotHoldingsTable.snapshotId] = snapshotId
this[SnapshotHoldingsTable.code] = stock.code
this[SnapshotHoldingsTable.name] = stock.name
// 포지션 ID 매핑: 메모리 맵에 없으면 신규 발급
val posId = activePositions.getOrPut(stock.code) {
"${stock.code}_${System.currentTimeMillis()}"
}
this[SnapshotHoldingsTable.positionId] = posId
this[SnapshotHoldingsTable.quantity] = stock.quantity.toIntOrNull() ?: 0
this[SnapshotHoldingsTable.avgPrice] = stock.avgPrice.toDoubleOrNull() ?: 0.0
this[SnapshotHoldingsTable.currentPrice] = stock.currentPrice.toDoubleOrNull() ?: 0.0
this[SnapshotHoldingsTable.profitRate] = stock.profitRate.toDoubleOrNull() ?: 0.0
this[SnapshotHoldingsTable.evalAmount] = stock.evalAmount.toLongOrNull() ?: 0L
this[SnapshotHoldingsTable.isDomestic] = stock.isDomestic
this[SnapshotHoldingsTable.isTodayEntry] = stock.isTodayEntry
}
}
println("📊 [Report] ${actualSnapshotType.name} 자산 스냅샷 저장 완료 (보유종목: ${balance.getHolldings().size}개)")
// 💡 4. 마감 플래그: END일 때만 리포트 생성 (MIDDLE은 데이터만 적재하고 패스)
if (actualSnapshotType == SnapshotType.END) {
println("🔔 [Report] 장 마감(END) 확인. 일간 매매 리포트 생성을 시작합니다.")
generateDailyLocalReport()
}
}
}
override fun recordTradeDecision(orderNo: String,
stockCode: String,
stockName: String,
isBuy: Boolean,
orderQty: Int,
reason: String,
holdingAvgPrice: Double,
decision: TradingDecision?) {
transaction(DatabaseFactory.reportDb) {
TradeHistoryTable.insert {
it[this.orderNo] = orderNo
it[this.stockCode] = stockCode
it[this.stockName] = stockName
it[this.orderQty] = orderQty // 처음에 주문한 총 수량 기억
it[orderTime] = LocalDateTime.now().toString()
it[this.isBuy] = isBuy
it[status] = "ORDERED"
it[this.reason] = reason
it[this.holdingAvgPrice] = holdingAvgPrice
// 💡 decision이 있으면 AI 데이터를 넣고, 없으면(기계적 매도) null로 깔끔하게 비움
it[currentPrice] = decision?.currentPrice
it[aiScore] = decision?.confidence
it[newsScore] = decision?.newsScore
it[systemScore] = decision?.systemScore
it[financialScore] = decision?.financialScore
it[technicalScore] = decision?.technicalScore
it[investmentGrade] = decision?.investmentGrade?.name
}
}
}
/**
* [3] 실제 체결 내역 업데이트 완전 체결(COMPLETED) 자동 판별
*/
override fun updateExecution(orderNo: String, execPrice: Double, execQty: Int, execTime: String?) {
transaction(DatabaseFactory.reportDb) {
val tradeRecord = TradeHistoryTable.select { TradeHistoryTable.orderNo eq orderNo }.singleOrNull()
if (tradeRecord != null) {
val internalTradeId = tradeRecord[TradeHistoryTable.id]
val code = tradeRecord[TradeHistoryTable.stockCode]
val isBuy = tradeRecord[TradeHistoryTable.isBuy]
val targetOrderQty = tradeRecord[TradeHistoryTable.orderQty]
// [포지션 라이프사이클 - 시작/레거시 처리]
if (isBuy && !activePositions.containsKey(code)) {
activePositions[code] = "${code}_${System.currentTimeMillis()}"
} else if (!isBuy && !activePositions.containsKey(code)) {
activePositions[code] = "${code}_legacy_position"
}
// 1. 체결 내역 Insert
val finalExecTime = execTime ?: LocalDateTime.now().toString()
ExecutionDetailsTable.insert {
it[tradeId] = internalTradeId
it[this.execTime] = finalExecTime
it[price] = execPrice
it[quantity] = execQty
}
// 2. 주문 상태 (PARTIAL / COMPLETED) 자동 판별
val totalExecutedQty = ExecutionDetailsTable
.select { ExecutionDetailsTable.tradeId eq internalTradeId }
.sumOf { it[ExecutionDetailsTable.quantity] }
TradeHistoryTable.update({ TradeHistoryTable.id eq internalTradeId }) {
it[status] = if (totalExecutedQty >= targetOrderQty) "COMPLETED" else "PARTIAL"
}
// 💡 3. [포지션 사이클 종료 처리 - 자율 계산 로직]
// 매도(SELL) 체결이 일어났을 때만, DB를 바탕으로 현재 남은 잔고를 스스로 계산합니다.
if (!isBuy) {
val todayDate = LocalDate.now().toString()
// A. 오늘 아침(START) 기준 해당 종목의 기초 잔고 가져오기 (테스트 중 데이터가 없으면 0으로 처리)
val startSnapshotId = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq todayDate) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.limit(1).singleOrNull()?.get(AssetSnapshotTable.id)
val startQty = if (startSnapshotId != null) {
SnapshotHoldingsTable.select {
(SnapshotHoldingsTable.snapshotId eq startSnapshotId) and (SnapshotHoldingsTable.code eq code)
}.singleOrNull()?.get(SnapshotHoldingsTable.quantity) ?: 0
} else 0
// B. 오늘 하루 동안 발생한 모든 매수 체결량(BUY) 합산
val todayBuyQty = (TradeHistoryTable innerJoin ExecutionDetailsTable)
.slice(ExecutionDetailsTable.quantity.sum())
.select {
(TradeHistoryTable.stockCode eq code) and
(TradeHistoryTable.isBuy eq true) and
(TradeHistoryTable.orderTime like "$todayDate%")
}.singleOrNull()?.get(ExecutionDetailsTable.quantity.sum()) ?: 0
// C. 오늘 하루 동안 발생한 모든 매도 체결량(SELL) 합산
val todaySellQty = (TradeHistoryTable innerJoin ExecutionDetailsTable)
.slice(ExecutionDetailsTable.quantity.sum())
.select {
(TradeHistoryTable.stockCode eq code) and
(TradeHistoryTable.isBuy eq false) and
(TradeHistoryTable.orderTime like "$todayDate%")
}.singleOrNull()?.get(ExecutionDetailsTable.quantity.sum()) ?: 0
// D. 리포팅 엔진이 추정한 현재 최종 잔고 = (기초 잔고 + 오늘 매수량 - 오늘 매도량)
val currentEstimatedQty = startQty + todayBuyQty - todaySellQty
// 추정 잔고가 0 이하가 되면 전량 매도로 간주하고 사이클을 닫음!
if (currentEstimatedQty <= 0) {
val closedPos = activePositions.remove(code)
if (closedPos != null) {
println("🏁 [Report] DB 계산결과 $code 종목 전량 매도 확인(잔고 0). 매매 사이클($closedPos) 내부 자동 종료.")
}
}
}
}
}
}
override fun closePositionCycle(stockCode: String) {
val closedPositionId = activePositions.remove(stockCode)
if (closedPositionId != null) {
println("🏁 [Position] $stockCode 종목 전량 매도로 매매 사이클($closedPositionId)이 종료 및 초기화되었습니다.")
}
}
/**
* [4] 마감 로컬 리포트(HTML) 자동 생성
*/
override fun generateDailyLocalReport() {
transaction(DatabaseFactory.reportDb) {
val today = LocalDate.now().toString()
val startAsset = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.orderBy(AssetSnapshotTable.id to SortOrder.ASC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: 0L
val endAsset = AssetSnapshotTable.select {
(AssetSnapshotTable.date eq today) and (AssetSnapshotTable.snapshotType eq SnapshotType.END.name)
}.orderBy(AssetSnapshotTable.id to SortOrder.DESC).limit(1).singleOrNull()?.get(AssetSnapshotTable.totalAsset) ?: startAsset
// 💡 [변경] isBuy == false 필터를 제거하여 금일 발생한 '모든' 주문 내역을 가져옵니다.
val allTrades = TradeHistoryTable.select {
(TradeHistoryTable.orderTime like "$today%")
}.orderBy(TradeHistoryTable.orderTime to SortOrder.DESC).toList()
val tradeLogs = allTrades.map { row ->
val tradeId = row[TradeHistoryTable.id]
val stockCode = row[TradeHistoryTable.stockCode]
val isBuy = row[TradeHistoryTable.isBuy]
val status = row[TradeHistoryTable.status]
val orderTimeStr = row[TradeHistoryTable.orderTime] // 예: "2024-05-20T09:10:00.123"
// 1. 체결 상세 정보 가져오기
val executions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq tradeId }.toList()
val execQty = executions.sumOf { it[ExecutionDetailsTable.quantity] }
// VWAP 평균 체결가 산출
val avgPrice = if (execQty > 0) {
executions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / execQty
} else {
row[TradeHistoryTable.currentPrice] ?: 0.0
}
// 💡 2. 주문부터 마지막 체결까지 소요된 시간 계산 로직
val lastExecTimeStr = executions.maxByOrNull { it[ExecutionDetailsTable.execTime] }?.get(ExecutionDetailsTable.execTime)
val timeTakenStr = if (lastExecTimeStr != null) {
try {
val orderTime = LocalDateTime.parse(orderTimeStr)
val lastExecTime = LocalDateTime.parse(lastExecTimeStr)
val duration = Duration.between(orderTime, lastExecTime)
val timeString = if (duration.toMinutes() > 0) {
"${duration.toMinutes()}${duration.seconds % 60}"
} else {
"${duration.toMillis() / 1000.0}"
}
// 아직 상태가 COMPLETED가 아니면 미완료 표시 (현재 상태값이 ORDERED, PARTIAL 등일 경우)
if (status == "COMPLETED") "완료 ($timeString)" else "미완료 ($timeString)"
} catch (e: Exception) {
"계산 불가"
}
} else {
"미체결 (진행중)"
}
// 3. 수익률 및 수익금 계산 (매도일 때만)
var calculatedProfitRate = 0.0
var calculatedProfitAmount = 0L
if (!isBuy) {
// 과거 매수 단가 찾기 (1순위: 이전 매수 기록, 2순위: 아침 스냅샷)
val recentBuyTrade = TradeHistoryTable.select {
(TradeHistoryTable.stockCode eq stockCode) and
(TradeHistoryTable.isBuy eq true) and
(TradeHistoryTable.id less tradeId)
}.orderBy(TradeHistoryTable.id to SortOrder.DESC).limit(1).singleOrNull()
var avgBuyPrice = 0.0
if (recentBuyTrade != null) {
val buyExecutions = ExecutionDetailsTable.select { ExecutionDetailsTable.tradeId eq recentBuyTrade[TradeHistoryTable.id] }.toList()
val buyQty = buyExecutions.sumOf { it[ExecutionDetailsTable.quantity] }
if (buyQty > 0) avgBuyPrice = buyExecutions.sumOf { it[ExecutionDetailsTable.price] * it[ExecutionDetailsTable.quantity] } / buyQty
}
// 2차 시도: 오늘 아침 START 스냅샷에 기록된 보유 평단가
if (avgBuyPrice == 0.0) {
val startHoldings = SnapshotHoldingsTable.innerJoin(AssetSnapshotTable).select {
(SnapshotHoldingsTable.code eq stockCode) and
(AssetSnapshotTable.date eq today) and
(AssetSnapshotTable.snapshotType eq SnapshotType.START.name)
}.singleOrNull()
avgBuyPrice = startHoldings?.get(SnapshotHoldingsTable.avgPrice) ?: 0.0
}
// 💡 3차 시도 (궁극의 방어막): 리포팅 도입 이전부터 들고 있던 레거시 종목일 경우
// 매도 주문 당시에 DB에 백업해두었던 증권사 보유 평단가를 그대로 사용합니다.
if (avgBuyPrice == 0.0) {
avgBuyPrice = row[TradeHistoryTable.holdingAvgPrice]
}
// --- [C] 최종 수익률 계산 ---
if (avgBuyPrice > 0.0) {
calculatedProfitRate = ((avgPrice - avgBuyPrice) / avgBuyPrice) * 100
calculatedProfitAmount = ((avgPrice - avgBuyPrice) * execQty).toLong()
} else {
calculatedProfitRate = 0.0
calculatedProfitAmount = 0L
}
}
// 4. DTO 조립
LocalReportGenerator.TradeDetailData(
isBuy = isBuy,
stockName = row[TradeHistoryTable.stockName],
orderTime = orderTimeStr,
timeTaken = timeTakenStr,
execQty = execQty,
avgPrice = avgPrice.toLong(),
profitRate = calculatedProfitRate,
profitAmount = calculatedProfitAmount,
reason = row[TradeHistoryTable.reason],
investmentGrade = row[TradeHistoryTable.investmentGrade],
aiScore = row[TradeHistoryTable.aiScore]
)
}
LocalReportGenerator.generateAndOpen(startAsset, endAsset, tradeLogs)
}
}
}

View File

@ -0,0 +1,77 @@
package report.database
import org.jetbrains.exposed.sql.Table
// 💡 [신규] 설정 변경 이력 전용 테이블
object ConfigHistoryTable : Table("config_history") {
val id = integer("id").autoIncrement()
val updatedAt = varchar("updated_at", 50) // 마지막 수정 시간
val configJson = text("config_json") // 설정값 JSON
override val primaryKey = PrimaryKey(id)
}
// [1] 자산 스냅샷 마스터 (설정 필드 제거)
object AssetSnapshotTable : Table("asset_snapshots") {
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 appliedConfigJson = text("applied_config")
val remark = varchar("remark", 255).nullable()
override val primaryKey = PrimaryKey(id)
}
// [2] 보유 종목 상세 (UnifiedStockHolding 기준)
object SnapshotHoldingsTable : Table("snapshot_holdings") {
val id = integer("id").autoIncrement()
val snapshotId = reference("snapshot_id", AssetSnapshotTable.id)
val code = varchar("code", 20)
val name = varchar("name", 100)
val quantity = integer("quantity")
val avgPrice = double("avg_price")
val positionId = varchar("position_id", 50).nullable().index()
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") // 당일 진입 여부
override val primaryKey = PrimaryKey(id)
}
// [3] 매매 이력 및 결정 근거 (TradingDecision + 수정된 TradeHistoryTable)
object TradeHistoryTable : Table("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")
val status = varchar("status", 20)
val orderQty = integer("order_qty").default(0)
val reason = text("reason") // AI 판단 전문
val currentPrice = double("current_price").nullable() // 결정 당시 가격
val aiScore = double("ai_score").nullable() // 종합 점수
val newsScore = double("news_score").nullable()
val systemScore = double("system_score").nullable()
val financialScore = double("financial_score").nullable()
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)
}
// [4] 체결 상세 (그대로 유지)
object ExecutionDetailsTable : Table("execution_details") {
val id = integer("id").autoIncrement()
val tradeId = reference("trade_id", TradeHistoryTable.id)
val execTime = varchar("exec_time", 50)
val price = double("price")
val quantity = integer("quantity")
override val primaryKey = PrimaryKey(id)
}

View File

@ -5,7 +5,6 @@ import Defines.AUTOSELL
import Defines.BLACKLISTEDSTOCKCODES import Defines.BLACKLISTEDSTOCKCODES
import Defines.EMBEDDING_PORT import Defines.EMBEDDING_PORT
import Defines.LLM_PORT import Defines.LLM_PORT
import network.TradingDecision
import TradingLogStore import TradingLogStore
import analyzer.AdvancedTradeAssistant import analyzer.AdvancedTradeAssistant
import analyzer.TechnicalAnalyzer import analyzer.TechnicalAnalyzer
@ -30,6 +29,7 @@ import model.ExecutionData
import model.KisSession import model.KisSession
import model.RankingStock import model.RankingStock
import model.RankingType import model.RankingType
import model.TradingDecision
import model.UnifiedBalance import model.UnifiedBalance
import model.UnifiedStockHolding import model.UnifiedStockHolding
import network.DartCodeManager import network.DartCodeManager
@ -38,6 +38,8 @@ import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import network.RagService import network.RagService
import network.StockUniverseLoader import network.StockUniverseLoader
import report.SnapshotType
import report.TradingReportManager
import util.MarketUtil import util.MarketUtil
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@ -222,6 +224,7 @@ object AutoTradingManager {
TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가")
} else { } else {
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo -> .onSuccess { realOrderNo ->
// 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가 // 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가
@ -250,6 +253,17 @@ object AutoTradingManager {
status = "PENDING_BUY", status = "PENDING_BUY",
isDomestic = true isDomestic = true
)) ))
TradingReportManager.recordTradeDecision(
orderNo = realOrderNo,
stockCode = stockCode,
stockName = stockName,
isBuy = true,
orderQty = inputQty,
reason = decision.reason ?: "", // AI 이유
decision = decision // AI 객체 통째로 전달
)
syncAndExecute(realOrderNo) syncAndExecute(realOrderNo)
// 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출
@ -296,7 +310,7 @@ object AutoTradingManager {
// 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정 // 3. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0)) val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
TradingReportManager.updateExecution(orderNo,finalTargetPrice,dbItem.quantity)
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
KisTradeService.postOrder( KisTradeService.postOrder(
@ -306,6 +320,15 @@ object AutoTradingManager {
isBuy = false isBuy = false
).onSuccess { newSellOrderNo -> ).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 객체 통째로 전달
)
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)") TradingLogStore.addSellLog(dbItem.name,finalTargetPrice.toString(),"SELL","🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
executionCache.remove(orderNo) executionCache.remove(orderNo)
@ -316,6 +339,8 @@ object AutoTradingManager {
} else if (dbItem.status == TradeStatus.SELLING) { } else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
myOredsAndBalanceCodes.remove(dbItem.code) myOredsAndBalanceCodes.remove(dbItem.code)
TradingReportManager.updateExecution(orderNo,execData.price.toDouble(),execData.qty.toInt())
TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리") TradingLogStore.addSellLog(dbItem.name,execData.price,"SELL","🎊 [매칭 성공] 매도 완료 처리")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo) executionCache.remove(orderNo)
@ -350,7 +375,7 @@ object AutoTradingManager {
} }
suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") {
balance.holdings.forEach { holding -> balance.getHolldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}") println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
@ -389,6 +414,19 @@ object AutoTradingManager {
"SELL", "SELL",
"🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료" "🎊 ${if(marketCode.equals("Y"))"시간외 단일가" else "대체거래소"} 주식 재고털이 주문 완료"
) )
DatabaseFactory.saveAutoTrade(AutoTradeItem(
orderNo = newOrderNo,
code = holding.code,
name = holding.name,
quantity = holding.quantity.toInt(),
profitRate = 0.0,
stopLossRate = 0.0,
targetPrice = targetPrice.toDouble(),
stopLossPrice = 0.0,
status = "SELLING",
isDomestic = true
))
syncAndExecute(newOrderNo)
}.onFailure { }.onFailure {
TradingLogStore.addSellLog( TradingLogStore.addSellLog(
"${holding.name}[${holding.code}]", "${holding.name}[${holding.code}]",
@ -415,7 +453,7 @@ object AutoTradingManager {
println("resumePendingSellOrders") println("resumePendingSellOrders")
balance.holdings.forEach { holding -> balance.getHolldings().forEach { holding ->
if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ if (BLACKLISTEDSTOCKCODES.contains(holding.code)){
println("❌ 차단 처리된 주식 : ${holding.name}") println("❌ 차단 처리된 주식 : ${holding.name}")
TradingLogStore.addAnalyzer( TradingLogStore.addAnalyzer(
@ -678,6 +716,10 @@ object AutoTradingManager {
suspend fun checkBalance(isMorning: Boolean = true) { suspend fun checkBalance(isMorning: Boolean = true) {
if (isMorning) { if (isMorning) {
currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull() currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull()
currentBalance?.let { currentBalance ->
TradingReportManager.recordAssetSnapshot(if (LocalTime.now().isAfter(LocalTime.of(17,58))) SnapshotType.END else SnapshotType.MIDDLE ,currentBalance,"")
}
if (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } if (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) }
} else { } else {
} }
@ -687,7 +729,7 @@ object AutoTradingManager {
myOredsAndBalanceCodes.clear() myOredsAndBalanceCodes.clear()
checkBalance() checkBalance()
val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L val myCash = currentBalance?.deposit?.replace(",", "")?.toLongOrNull() ?: 0L
val myHoldings = currentBalance?.holdings?.filter { it.quantity.toInt() > 0 }?.map { val myHoldings = currentBalance?.getHolldings()?.map {
myOredsAndBalanceCodes.add(it.code) myOredsAndBalanceCodes.add(it.code)
it.code }?.toSet() ?: emptySet() it.code }?.toSet() ?: emptySet()
val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map { val pendingStocks = DatabaseFactory.findAllMonitoringTrades().map {
@ -790,53 +832,6 @@ object AutoTradingManager {
} }
} }
suspend fun finalizeMarketClose(now: LocalTime) {
when {
(AutoTradingManager.now.hour == 0 && AutoTradingManager.now.minute == 0 && (isSystemReadyToday || isSystemCleanedUpToday)) -> {
waitTime = 10.0
isSystemReadyToday = false
isSystemCleanedUpToday = false
}
(AutoTradingManager.now.isAfter(LocalTime.of(8, 0)) && !isSystemReadyToday) -> {
waitTime = 3.0
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
KisWebSocketManager.disconnect()
tryRefreshToken()
}
}
(AutoTradingManager.now.isAfter(LocalTime.of(18, 20))) -> {
try {
waitTime = 5.0
println("current SystemCleanedUpToday is $isSystemCleanedUpToday")
if (!isSystemCleanedUpToday) {
println("🌙 [System] 업무 종료 및 자원 정리 시작...")
SystemSleepPreventer.sleepDisplay() // 모니터 끄기
KisWebSocketManager.disconnect()
BrowserManager.closeIfIdle(0) // 즉시 닫기
if (LlamaServerManager.stopAll()) {
isSystemCleanedUpToday = true
}
}
println("✅ [System] 오늘의 모든 정리가 완료되었습니다.")
} catch (e: Exception) {
}
}
(AutoTradingManager.now.isAfter(LocalTime.of(18, 15)) && AutoTradingManager.now.minute % 15 == 0) -> {
try {
waitTime = 5.0
SystemSleepPreventer.sleepDisplay() // 모니터 끄기
} catch (e: Exception) {
}
}
else -> {
waitTime = 5.0
}
}
}
fun addToReanalysis(stock: RankingStock) { fun addToReanalysis(stock: RankingStock) {
val count = retryCountMap.getOrDefault(stock.code, 0) val count = retryCountMap.getOrDefault(stock.code, 0)
@ -953,7 +948,7 @@ object AutoTradingManager {
private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { private suspend fun executeClosingLiquidation(tradeService: KisTradeService) {
val activeTrades = DatabaseFactory.findAllMonitoringTrades() val activeTrades = DatabaseFactory.findAllMonitoringTrades()
val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() val balanceResult = tradeService.fetchIntegratedBalance().getOrNull()
val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() val realHoldings = balanceResult?.getHolldings()?.associateBy { it.code } ?: emptyMap()
activeTrades.forEach { trade -> activeTrades.forEach { trade ->
try { try {