diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index e08d9e3..e6c120a 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -20,6 +20,7 @@ import ConfigTable.max_count import ConfigTable.max_holding_count import ConfigTable.stop_Loss import ConfigTable.take_profit +import DatabaseFactory.mainDb import Defines.DETAILLOG import Defines.EMBEDDING_PORT import Defines.LLM_PORT @@ -232,7 +233,7 @@ fun main() = application { // 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입) LaunchedEffect(Unit) { DatabaseFactory.init() - transaction { + transaction(mainDb) { ConfigTable.selectAll().lastOrNull()?.let { KisSession.config = AppConfig( realAppKey = it[ConfigTable.realAppKey], diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 656d0f2..8eb3c38 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -1,11 +1,17 @@ import androidx.compose.runtime.mutableStateListOf import kotlinx.serialization.Serializable import model.AppConfig -import network.TradingDecision +import model.TradingDecision import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.javatime.datetime 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.time.LocalDateTime import java.time.LocalTime @@ -116,21 +122,40 @@ object HolidayTable : Table("holiday_cache") { } object DatabaseFactory { + val reporter: TradingReportService get() = TradingReportManager + lateinit var mainDb: Database + lateinit var reportDb: Database + fun init() { - val dbPath = File("db/autotrade_db").absolutePath - Database.connect( - "jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;", + mainDb = Database.connect( + "jdbc:h2:${File("db/autotrade_db").absolutePath};DB_CLOSE_DELAY=-1;", 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 포함) SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,HolidayTable) } + + + } - fun saveHoliday(date: String, holiday: Boolean) = transaction { + fun saveHoliday(date: String, holiday: Boolean) = transaction(mainDb) { HolidayTable.replace { it[bassDt] = date 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 } .map { it[HolidayTable.isHoliday] } .singleOrNull() @@ -147,7 +172,7 @@ object DatabaseFactory { /** * 새로운 자동매매 건 등록 (주로 PENDING_BUY 상태로 시작) */ - fun saveAutoTrade(item: AutoTradeItem) = transaction { + fun saveAutoTrade(item: AutoTradeItem) = transaction(mainDb) { AutoTradeTable.insert { it[stockCode] = item.code it[stockName] = item.name @@ -165,7 +190,7 @@ object DatabaseFactory { /** * 상태 변경 및 가격 업데이트 (예: PENDING_BUY -> MONITORING) */ - fun updateAutoTrade(item: AutoTradeItem) = transaction { + fun updateAutoTrade(item: AutoTradeItem) = transaction(mainDb) { val id = item.id ?: return@transaction AutoTradeTable.update({ AutoTradeTable.id eq id }) { it[targetPrice] = item.targetPrice @@ -178,7 +203,7 @@ object DatabaseFactory { /** * 감시 중인 모든 종목 리스트 반환 (ActiveTradeSection UI용) */ - fun getActiveAutoTrades(): List = transaction { + fun getActiveAutoTrades(): List = transaction(mainDb) { AutoTradeTable.select { AutoTradeTable.status inList listOf("MONITORING", "SELLING", "PENDING_BUY") }.map { mapToAutoTradeItem(it) } @@ -187,18 +212,18 @@ object DatabaseFactory { /** * 종목코드로 현재 감시 중인 설정이 있는지 확인 (UI 체크박스 상태용) */ - fun findConfigByCode(code: String): AutoTradeItem? = transaction { + fun findConfigByCode(code: String): AutoTradeItem? = transaction(mainDb) { AutoTradeTable.select { (AutoTradeTable.stockCode eq code) and (AutoTradeTable.status eq "MONITORING") }.lastOrNull()?.let { mapToAutoTradeItem(it) } } - fun deleteAutoTrade(id: Int) = transaction { + fun deleteAutoTrade(id: Int) = transaction(mainDb) { AutoTradeTable.deleteWhere { AutoTradeTable.id eq id } } fun findAllPendingBuyCodes(): Set { - return transaction { + return transaction(mainDb) { AutoTradeTable.select { (AutoTradeTable.status eq "PENDING_BUY") or (AutoTradeTable.status eq "ORDERED") }.map { it[AutoTradeTable.stockCode] }.toSet() @@ -206,7 +231,7 @@ object DatabaseFactory { } fun findAllMonitoringTrades(): List { - return transaction { + return transaction(mainDb) { AutoTradeTable.select { AutoTradeTable.status neq "COMPLETED" }.map { mapToAutoTradeItem(it) } @@ -230,7 +255,7 @@ object DatabaseFactory { // --- 기존 설정 및 로그 관련 함수 --- fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) { - transaction { + transaction(mainDb) { TradeLogTable.insert { it[stockCode] = code 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.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo) }.lastOrNull()?.let { @@ -287,8 +312,8 @@ object DatabaseFactory { stop_Loss = it[ConfigTable.stop_Loss], take_profit = it[ConfigTable.take_profit], loss_min = it[ConfigTable.loss_minrate], - loss_max = it[ConfigTable.loss_maxrate], - loss_money = it[ConfigTable.loss_max_money], + loss_max = it[ConfigTable.loss_maxrate], + loss_money = it[ConfigTable.loss_max_money], MAX_COUNT = it[ConfigTable.max_count], max_holding_count = it[ConfigTable.max_holding_count], ) @@ -296,7 +321,7 @@ object DatabaseFactory { } fun saveConfig(config: AppConfig) { - transaction { + transaction(mainDb) { ConfigTable.deleteAll() ConfigTable.insert { 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() if (existing == null) { 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 } .map { mapToAutoTradeItem(it) } .singleOrNull() @@ -380,7 +405,7 @@ object DatabaseFactory { /** * 서버 동기화: DB에는 PENDING_BUY/MONITORING인데 서버 미체결 내역에 없는 경우 EXPIRED로 변경 */ - fun syncWithServer(serverOrderNos: List) = transaction { + fun syncWithServer(serverOrderNos: List) = transaction(mainDb) { AutoTradeTable.update({ (AutoTradeTable.status inList listOf(TradeStatus.PENDING_BUY, TradeStatus.MONITORING)) and (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 }) { it[status] = newStatus if (newOrderNo != null) it[orderNo] = newOrderNo @@ -402,7 +427,7 @@ object DatabaseFactory { /** * 감시 중인 모든 종목 리스트 (Status별 필터링 용이하게 수정) */ - fun getAutoTradesByStatus(statusList: List): List = transaction { + fun getAutoTradesByStatus(statusList: List): List = transaction(mainDb) { AutoTradeTable.select { AutoTradeTable.status inList statusList } .map { mapToAutoTradeItem(it) } } diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index 8cc9136..0dea069 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -164,8 +164,10 @@ data class UnifiedBalance( val totalAsset: String, // 총 평가자산 val totalProfitRate: String, // 총 수익률 val deposit: String, - val holdings: List // 통합 보유 종목 리스트 -) + private val holdings: List // 통합 보유 종목 리스트 +) { + fun getHolldings() = holdings.filter { it.quantity.toInt() > 0 } +} @Serializable diff --git a/src/main/kotlin/model/TradeModels.kt b/src/main/kotlin/model/TradeModels.kt index f82f53a..0f56605 100644 --- a/src/main/kotlin/model/TradeModels.kt +++ b/src/main/kotlin/model/TradeModels.kt @@ -1,6 +1,9 @@ package model +import analyzer.ScalpingSignalModel +import analyzer.TechnicalAnalyzer import kotlinx.serialization.Serializable +import service.InvestmentGrade @Serializable data class RealTimeTrade( @@ -12,4 +15,88 @@ data class RealTimeTrade( val type: TradeType // 매수체결(빨강) / 매도체결(파랑) ) -enum class TradeType { BUY, SELL, NEUTRAL } \ No newline at end of file +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(ultraShortScore, + shortTermScore).average() + + fun profitPossible() = + listOf(ultraShortScore, + shortTermScore, + midTermScore, + longTermScore).average() + + fun safePossible() = + listOf( + 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 + + diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index ec5a611..6ea11bb 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -41,6 +41,7 @@ import kotlinx.serialization.json.putJsonObject import model.ConfigIndex import model.KisSession import model.RankingStock +import model.TradingDecision import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient 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(ultraShortScore, - shortTermScore).average() - - fun profitPossible() = - listOf(ultraShortScore, - shortTermScore, - midTermScore, - longTermScore).average() - - fun safePossible() = - listOf( - 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 - - diff --git a/src/main/kotlin/report/LocalReportGenerator.kt b/src/main/kotlin/report/LocalReportGenerator.kt new file mode 100644 index 0000000..0facf1f --- /dev/null +++ b/src/main/kotlin/report/LocalReportGenerator.kt @@ -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) { + 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()) { + "금일 발생한 주문 내역이 없습니다." + } else { + tradeLogs.joinToString("\n") { trade -> + // 매수(빨강), 매도(파랑) 뱃지 + val typeBadge = if (trade.isBuy) "매수" else "매도" + + // 시간 포맷팅 + 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("미완료")) { + "${trade.timeTaken}" + } else { + "${trade.timeTaken}" + } + + val gradeHtml = trade.investmentGrade?.let { "$it" } ?: "" + + """ + + $typeBadge + ${trade.stockName} $gradeHtml + $orderTimeParsed + $timeStatusHtml + ${String.format("%,d", trade.execQty)}주 + ${String.format("%,d", trade.avgPrice)}원 + $rateText + $amountText + + + +
+ 💡 + AI (${String.format("%.1f", trade.aiScore ?: 0.0)}점): ${trade.reason.replace("\n", " ")} +
+ + + """.trimIndent() + } + } + + // 2. 전체 HTML 템플릿 + val htmlTemplate = """ + + + + + ATRADE 일간 리포트 - $today + + + +
+
+

ATRADE 일간 매매 상세 리포트

+
+
+

오늘의 실현 손익 (END - START)

+

$profitSign${String.format("%,d", profitAmount)}원 ($profitSign${String.format("%.2f", profitRate)}%)

+
+
+

📊 금일 주문 및 체결 내역 전체

+ + + + + + + + + + + + + + + $tradeRowsHtml + +
구분종목명주문시각소요시간/상태체결량평균단가수익률수익금
+
+
+ + + """.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() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/report/TradingReportManager.kt b/src/main/kotlin/report/TradingReportManager.kt new file mode 100644 index 0000000..9970f71 --- /dev/null +++ b/src/main/kotlin/report/TradingReportManager.kt @@ -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() + + 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) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/report/database/ReportTables.kt b/src/main/kotlin/report/database/ReportTables.kt new file mode 100644 index 0000000..4a43496 --- /dev/null +++ b/src/main/kotlin/report/database/ReportTables.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 70f53fa..2879706 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -5,7 +5,6 @@ import Defines.AUTOSELL import Defines.BLACKLISTEDSTOCKCODES import Defines.EMBEDDING_PORT import Defines.LLM_PORT -import network.TradingDecision import TradingLogStore import analyzer.AdvancedTradeAssistant import analyzer.TechnicalAnalyzer @@ -30,6 +29,7 @@ import model.ExecutionData import model.KisSession import model.RankingStock import model.RankingType +import model.TradingDecision import model.UnifiedBalance import model.UnifiedStockHolding import network.DartCodeManager @@ -38,6 +38,8 @@ import network.KisTradeService import network.KisWebSocketManager import network.RagService import network.StockUniverseLoader +import report.SnapshotType +import report.TradingReportManager import util.MarketUtil import java.time.LocalDate import java.time.LocalDateTime @@ -222,6 +224,7 @@ object AutoTradingManager { TradingLogStore.addLog(decision,"WATCH","매수 실패 : 최대 보유 종목 도달로 신규 매수 일시 중단 => 재분석 대기열에 추가") } else { println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice") + KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true) .onSuccess { realOrderNo -> // 💡 [개선 1] 첫 번째 성공 로그에 등급 이름 추가 @@ -250,6 +253,17 @@ object AutoTradingManager { status = "PENDING_BUY", isDomestic = true )) + + TradingReportManager.recordTradeDecision( + orderNo = realOrderNo, + stockCode = stockCode, + stockName = stockName, + isBuy = true, + orderQty = inputQty, + reason = decision.reason ?: "", // AI 이유 + decision = decision // AI 객체 통째로 전달 + ) + syncAndExecute(realOrderNo) // 💡 [개선 3] 감시 설정 로그에도 등급 정보 노출 @@ -296,7 +310,7 @@ object AutoTradingManager { // 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)}% 적용)") KisTradeService.postOrder( @@ -306,6 +320,15 @@ object AutoTradingManager { 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 객체 통째로 전달 + ) 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) @@ -316,6 +339,8 @@ object AutoTradingManager { } else if (dbItem.status == TradeStatus.SELLING) { println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") myOredsAndBalanceCodes.remove(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) @@ -350,7 +375,7 @@ object AutoTradingManager { } suspend fun sellingAfterMarketOnePrice(tradeService: KisTradeService,balance : UnifiedBalance,marketCode : String = "Y") { - balance.holdings.forEach { holding -> + balance.getHolldings().forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( @@ -389,6 +414,19 @@ object AutoTradingManager { "SELL", "🎊 ${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 { TradingLogStore.addSellLog( "${holding.name}[${holding.code}]", @@ -415,7 +453,7 @@ object AutoTradingManager { println("resumePendingSellOrders") - balance.holdings.forEach { holding -> + balance.getHolldings().forEach { holding -> if (BLACKLISTEDSTOCKCODES.contains(holding.code)){ println("❌ 차단 처리된 주식 : ${holding.name}") TradingLogStore.addAnalyzer( @@ -678,6 +716,10 @@ object AutoTradingManager { suspend fun checkBalance(isMorning: Boolean = true) { 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 (AUTOSELL) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } } else { } @@ -687,7 +729,7 @@ object AutoTradingManager { myOredsAndBalanceCodes.clear() checkBalance() 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) it.code }?.toSet() ?: emptySet() 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) { val count = retryCountMap.getOrDefault(stock.code, 0) @@ -953,7 +948,7 @@ object AutoTradingManager { private suspend fun executeClosingLiquidation(tradeService: KisTradeService) { val activeTrades = DatabaseFactory.findAllMonitoringTrades() val balanceResult = tradeService.fetchIntegratedBalance().getOrNull() - val realHoldings = balanceResult?.holdings?.associateBy { it.code } ?: emptyMap() + val realHoldings = balanceResult?.getHolldings()?.associateBy { it.code } ?: emptyMap() activeTrades.forEach { trade -> try {