2026-04-16 15:48:23 +09:00
package report
import io.ktor.utils.io.core.String
2026-04-17 18:08:53 +09:00
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
2026-04-16 15:48:23 +09:00
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.*
2026-04-17 18:08:53 +09:00
import network.KisTradeService
2026-04-16 15:48:23 +09:00
import service.InvestmentGrade
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
2026-04-17 18:08:53 +09:00
import org.jetbrains.exposed.dao.id.IntIdTable
2026-04-16 15:48:23 +09:00
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 > ( )
2026-04-17 18:08:53 +09:00
override fun recordAssetSnapshot ( type : SnapshotType , balance : UnifiedBalance , remark : String ? ) {
2026-05-13 11:37:57 +09:00
if ( ! KisSession . tradeConfig . useAutoRepost ) {
return
}
2026-04-17 18:08:53 +09:00
CoroutineScope ( Dispatchers . IO ) . launch {
val todayDate = LocalDate . now ( ) . toString ( )
// 1. 중복 없는 전체 종목 코드 리스트 추출
val allCodes = mutableSetOf < String > ( )
val holdings = balance . getHoldings ( )
allCodes . addAll ( holdings . map { it . code } )
// 거래 내역 테이블에서 당일 거래된 종목 코드 추가
val tradedCodes = transaction ( DatabaseFactory . reportDb ) {
TradeHistoryTable . select { TradeHistoryTable . orderTime like " $todayDate % " }
. map { it [ TradeHistoryTable . stockCode ] }
}
allCodes . addAll ( tradedCodes )
// 2. [핵심] 시세 데이터 일괄 병렬 조회 (Cache 구성)
val priceCache = mutableMapOf < String , CandleData ? > ( )
if ( type == SnapshotType . END ) {
allCodes . chunked ( 10 ) . forEach { batch -> // API 부하 조절을 위해 10개씩 묶음 처리 가능
batch . map { code ->
async {
code to KisTradeService . fetchPeriodChartData ( code , " D " , true ) . getOrNull ( ) ?. lastOrNull ( )
}
} . awaitAll ( ) . forEach { ( code , data ) ->
priceCache [ code ] = data
}
delay ( 200 ) // API 초당 호출 제한(TPS) 준수
}
}
transaction ( DatabaseFactory . reportDb ) {
// 1. 자산 스냅샷 저장
val snapshotId = AssetSnapshotTable . insertAndGetId {
it [ AssetSnapshotTable . date ] = todayDate
it [ AssetSnapshotTable . snapshotType ] = type . name
// 기존 데이터 유지 부분
it [ AssetSnapshotTable . totalAsset ] = balance . totalAsset . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L
it [ AssetSnapshotTable . totalProfitRate ] =
balance . totalProfitRate . replace ( " % " , " " ) . toDoubleOrNull ( ) ?: 0.0
it [ AssetSnapshotTable . deposit ] =
balance . deposit . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L // 👈 [추가] 에러 원인 해결!
// 신규 리포트용 데이터 부분
it [ AssetSnapshotTable . dailyAssetChange ] =
balance . dailyAssetChange . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L
it [ AssetSnapshotTable . todayFees ] = balance . todayFees . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L
// it[AssetSnapshotTable.appliedConfigJson] = "{}" // 👈 [추가] 스키마상 필수로 지정되어 있어 빈 값이라도 넣어줍니다.
it [ AssetSnapshotTable . remark ] = remark
} . value
// 2. 보유 종목 스냅샷 저장
if ( balance . getHoldings ( ) . isNotEmpty ( ) ) {
SnapshotHoldingsTable . batchInsert ( balance . getHoldings ( ) ) { stock ->
this [ SnapshotHoldingsTable . snapshotId ] = snapshotId
this [ SnapshotHoldingsTable . code ] = stock . code
this [ SnapshotHoldingsTable . name ] = stock . name
this [ SnapshotHoldingsTable . quantity ] = stock . quantity . toIntOrNull ( ) ?: 0
this [ SnapshotHoldingsTable . avgPrice ] = stock . avgPrice . toDoubleOrNull ( ) ?: 0.0
this [ SnapshotHoldingsTable . currentPrice ] = stock . currentPrice . toDoubleOrNull ( ) ?: 0.0
this [ SnapshotHoldingsTable . profitRate ] = stock . profitRate . toDoubleOrNull ( ) ?: 0.0
this [ SnapshotHoldingsTable . evalAmount ] = stock . evalAmount . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L
this [ SnapshotHoldingsTable . isDomestic ] = stock . isDomestic
this [ SnapshotHoldingsTable . isTodayEntry ] = stock . isTodayEntry
}
}
val startAsset = AssetSnapshotTable . select {
( AssetSnapshotTable . date eq todayDate ) and ( AssetSnapshotTable . snapshotType eq SnapshotType . START . name )
} . orderBy ( AssetSnapshotTable . id to SortOrder . ASC ) . limit ( 1 ) . singleOrNull ( )
?. get ( AssetSnapshotTable . totalAsset )
?: balance . totalAsset . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L
// 3. 🚀 요약 데이터 Raw 패키징
val summaryData = LocalReportGenerator . RawSummaryData (
type = type . name ,
startAsset = startAsset ,
endAsset = balance . totalAsset . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L ,
dailyAssetChange = balance . dailyAssetChange . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L ,
todayFees = balance . todayFees . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L ,
totalProfitRate = balance . totalProfitRate . replace ( " % " , " " ) . toDoubleOrNull ( ) ?: 0.0
)
// 4. 🚀 보유 잔고 데이터 Raw 패키징 (수익률 계산은 Generator에 위임)
val holdingLogs = balance . getHoldings ( ) . map { stock ->
var o = 0.0 ;
var h = 0.0 ;
var l = 0.0
var c = 0.0
// 💡 장 마감 리포트일 때만 시세 API 호출
if ( type == SnapshotType . END ) {
runBlocking {
priceCache [ stock . code ] ?. let {
o = it . stck _oprc . toDoubleOrNull ( ) ?: 0.0
h = it . stck _hgpr . toDoubleOrNull ( ) ?: 0.0
l = it . stck _lwpr . toDoubleOrNull ( ) ?: 0.0
c = it . stck _prpr . toDoubleOrNull ( ) ?: 0.0
}
}
}
LocalReportGenerator . RawHoldingData (
stockName = stock . name ,
quantity = stock . quantity . toIntOrNull ( ) ?: 0 ,
avgPrice = stock . avgPrice . toDoubleOrNull ( ) ?: 0.0 ,
currentPrice = stock . currentPrice . toDoubleOrNull ( ) ?: 0.0 ,
evalAmount = stock . evalAmount . replace ( " , " , " " ) . toLongOrNull ( ) ?: 0L ,
openPrice = o , highPrice = h , lowPrice = l , closePrice = c ,
)
}
// 5. 🚀 당일 거래 내역 Raw 패키징
val tradeLogs = TradeHistoryTable . select {
TradeHistoryTable . orderTime like " $todayDate % "
} . orderBy ( TradeHistoryTable . orderTime to SortOrder . ASC ) . map { row ->
val code = row [ TradeHistoryTable . stockCode ]
var o = 0.0 ;
var h = 0.0 ;
var l = 0.0 ;
var c = 0.0
if ( type == SnapshotType . END ) {
runBlocking {
priceCache [ code ] ?. let {
o = it . stck _oprc . toDoubleOrNull ( ) ?: 0.0
h = it . stck _hgpr . toDoubleOrNull ( ) ?: 0.0
l = it . stck _lwpr . toDoubleOrNull ( ) ?: 0.0
c = it . stck _prpr . toDoubleOrNull ( ) ?: 0.0
}
}
}
// 주의: 시간순(ASC)으로 정렬해서 넘겨야 제너레이터가 시간 흐름대로 묶기 편합니다.
val tradeId = row [ TradeHistoryTable . id ] . value
val rawExecutions =
ExecutionDetailsTable . select { ExecutionDetailsTable . tradeId eq tradeId } . map { exec ->
LocalReportGenerator . RawExecutionData (
price = exec [ ExecutionDetailsTable . price ] ,
quantity = exec [ ExecutionDetailsTable . quantity ] ,
execTime = exec [ ExecutionDetailsTable . execTime ]
)
}
// 매도일 경우에만 매수 단가 추적 (DB 조회가 필요하므로 이 작업만 매니저가 수행)
val isBuy = row [ TradeHistoryTable . isBuy ]
val resolvedBuyPrice =
if ( !is Buy ) findBuyPrice ( row [ TradeHistoryTable . stockCode ] , tradeId , todayDate ) else 0.0
LocalReportGenerator . RawTradeData (
stockCode = row [ TradeHistoryTable . stockCode ] , // 💡 [추가] 제너레이터가 종목별로 묶을 수 있도록 코드값 전달
stockName = row [ TradeHistoryTable . stockName ] ,
isBuy = isBuy ,
status = row [ TradeHistoryTable . status ] ,
orderTime = row [ TradeHistoryTable . orderTime ] ,
executions = rawExecutions ,
currentPrice = row [ TradeHistoryTable . currentPrice ] ?: 0.0 ,
resolvedBuyPrice = resolvedBuyPrice ,
investmentGrade = row [ TradeHistoryTable . investmentGrade ] ?: " - " ,
reason = row [ TradeHistoryTable . reason ] ?: " " ,
aiScore = row [ TradeHistoryTable . aiScore ] ?: 0.0 ,
openPrice = o ,
highPrice = h ,
lowPrice = l ,
closePrice = c ,
)
}
// 6. 코루틴 기반 제너레이터 호출
LocalReportGenerator . generateAndOpenAsync ( summaryData , holdingLogs , tradeLogs )
}
}
}
2026-04-16 15:48:23 +09:00
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] 새로운 설정 변경 이력이 저장되었습니다. " )
}
}
2026-04-17 18:08:53 +09:00
// ==========================================
// 3. 내부 유틸리티 함수
// ==========================================
private fun calculateTimeTaken ( orderTimeStr : String , lastExecTimeStr : String ? , status : String ) : String {
if ( lastExecTimeStr == null ) return " 미체결 (진행중) "
return try {
val orderTime = LocalDateTime . parse ( orderTimeStr )
val lastExecTime = LocalDateTime . parse ( lastExecTimeStr )
val duration = java . time . Duration . between ( orderTime , lastExecTime )
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
val timeString = if ( duration . toMinutes ( ) > 0 ) {
" ${duration.toMinutes()} 분 ${duration.seconds % 60} 초 "
2026-04-16 15:48:23 +09:00
} else {
2026-04-17 18:08:53 +09:00
" ${duration.toMillis() / 1000.0} 초 "
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
if ( status == " COMPLETED " ) " 완료 ( $timeString ) " else " 미완료 ( $timeString ) "
} catch ( e : Exception ) {
" 계산 불가 "
}
}
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
private fun findBuyPrice ( stockCode : String , currentTradeId : Int , todayDate : String ) : Double {
// 1차 방어선: 당일 체결된 매수 내역 중 확정된 purchasePrice가 있는지 확인
val recentBuyTrade = TradeHistoryTable . select {
( TradeHistoryTable . stockCode eq stockCode ) and
( TradeHistoryTable . isBuy eq true ) and
( TradeHistoryTable . id less currentTradeId )
} . orderBy ( TradeHistoryTable . id to SortOrder . DESC ) . limit ( 1 ) . singleOrNull ( )
if ( recentBuyTrade != null ) {
val pPrice = recentBuyTrade [ TradeHistoryTable . purchasePrice ]
if ( pPrice > 0.0 ) return pPrice // 💡 새로 추가된 확정 단가 최우선 사용
// 만약 없다면 체결 내역에서 계산 (기존 방식)
val buyExecutions = ExecutionDetailsTable . select {
ExecutionDetailsTable . tradeId eq recentBuyTrade [ TradeHistoryTable . id ]
} . toList ( )
val buyQty = buyExecutions . sumOf { it [ ExecutionDetailsTable . quantity ] }
if ( buyQty > 0 ) {
return buyExecutions . sumOf { it [ ExecutionDetailsTable . price ] * it [ ExecutionDetailsTable . quantity ] } / buyQty
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
}
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
// 2차 방어선: 당일 아침 START 스냅샷
val startSnapshotId = AssetSnapshotTable . select {
( AssetSnapshotTable . date eq todayDate ) and
( AssetSnapshotTable . snapshotType eq SnapshotType . START . name )
} . limit ( 1 ) . singleOrNull ( ) ?. get ( AssetSnapshotTable . id )
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
if ( startSnapshotId != null ) {
val startAvgPrice = SnapshotHoldingsTable . select {
( SnapshotHoldingsTable . snapshotId eq startSnapshotId ) and
( SnapshotHoldingsTable . code eq stockCode )
} . singleOrNull ( ) ?. get ( SnapshotHoldingsTable . avgPrice )
2026-04-16 15:48:23 +09:00
2026-04-17 18:08:53 +09:00
if ( startAvgPrice != null && startAvgPrice > 0 ) return startAvgPrice
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
// 3차 방어선: 레거시 보유 평단가
return TradeHistoryTable . select { TradeHistoryTable . id eq currentTradeId }
. singleOrNull ( ) ?. get ( TradeHistoryTable . holdingAvgPrice ) ?: 0.0
2026-04-16 15:48:23 +09:00
}
2026-04-17 18:08:53 +09:00
2026-04-16 15:48:23 +09:00
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 ( !is Buy && ! 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 ( !is Buy ) {
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 ( ) {
2026-04-17 18:08:53 +09:00
// 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)
// }
2026-04-16 15:48:23 +09:00
}
}