리포팅 테스트
This commit is contained in:
parent
68a7a05961
commit
ed12d07bc2
@ -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],
|
||||||
|
|||||||
@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
166
src/main/kotlin/report/LocalReportGenerator.kt
Normal file
166
src/main/kotlin/report/LocalReportGenerator.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/main/kotlin/report/TradingReportManager.kt
Normal file
455
src/main/kotlin/report/TradingReportManager.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/main/kotlin/report/database/ReportTables.kt
Normal file
77
src/main/kotlin/report/database/ReportTables.kt
Normal 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)
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user