....
This commit is contained in:
parent
8013e80a34
commit
fcd6b5e800
@ -46,9 +46,10 @@ import service.SystemSleepPreventer
|
|||||||
import service.TradingDecisionCallback
|
import service.TradingDecisionCallback
|
||||||
import ui.DashboardScreen
|
import ui.DashboardScreen
|
||||||
import ui.SettingsScreen
|
import ui.SettingsScreen
|
||||||
|
import ui.TradingDecisionLog
|
||||||
|
|
||||||
// 화면 상태 정의
|
// 화면 상태 정의
|
||||||
enum class AppScreen { Settings, Dashboard }
|
enum class AppScreen { Settings, Dashboard, TradingDecision }
|
||||||
|
|
||||||
|
|
||||||
fun getLlamaBinPath(): String {
|
fun getLlamaBinPath(): String {
|
||||||
@ -75,26 +76,10 @@ fun getLlamaBinPath(): String {
|
|||||||
fun main() = application {
|
fun main() = application {
|
||||||
SystemSleepPreventer.start()
|
SystemSleepPreventer.start()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// NewsService나 KisTradeService에서 사용하는 client를 전달
|
|
||||||
// DartCodeManager.updateCorpCodes(HttpClient(CIO) {
|
|
||||||
// install(ContentNegotiation) {
|
|
||||||
// json(Json {
|
|
||||||
// ignoreUnknownKeys = true
|
|
||||||
// encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// // [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
|
||||||
// install(Logging) {
|
|
||||||
// logger = Logger.DEFAULT
|
|
||||||
// level = LogLevel.BODY
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
|
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
|
||||||
val binPath = getLlamaBinPath()
|
val binPath = getLlamaBinPath()
|
||||||
val windowState = rememberWindowState(
|
val windowState = rememberWindowState(
|
||||||
placement = WindowPlacement.Maximized
|
placement = WindowPlacement.Fullscreen
|
||||||
)
|
)
|
||||||
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) {
|
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매", state = windowState) {
|
||||||
var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
|
var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
|
||||||
@ -127,6 +112,7 @@ fun main() = application {
|
|||||||
MAX_PRICE = it[ConfigTable.max_price],
|
MAX_PRICE = it[ConfigTable.max_price],
|
||||||
MIN_PRICE = it[ConfigTable.min_price],
|
MIN_PRICE = it[ConfigTable.min_price],
|
||||||
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
|
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
|
||||||
|
SELL_PROFIT = it[ConfigTable.sell_profit],
|
||||||
GRADE_5_BUY = it[grade_5_buy],
|
GRADE_5_BUY = it[grade_5_buy],
|
||||||
GRADE_5_PROFIT = it[grade_5_profit],
|
GRADE_5_PROFIT = it[grade_5_profit],
|
||||||
GRADE_4_BUY = it[grade_4_buy],
|
GRADE_4_BUY = it[grade_4_buy],
|
||||||
@ -143,6 +129,7 @@ fun main() = application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoaded = true
|
isLoaded = true
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
@ -158,7 +145,10 @@ fun main() = application {
|
|||||||
val config = KisSession.config
|
val config = KisSession.config
|
||||||
AutoTradingManager.isSystemReadyToday = true
|
AutoTradingManager.isSystemReadyToday = true
|
||||||
AutoTradingManager.isSystemCleanedUpToday = false
|
AutoTradingManager.isSystemCleanedUpToday = false
|
||||||
// LLM 서버 시작 (설정된 모델 경로 사용)
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
AutoTradingManager.startAutoDiscoveryLoop()
|
||||||
|
}
|
||||||
|
|
||||||
if (config.modelPath.isNotEmpty()) {
|
if (config.modelPath.isNotEmpty()) {
|
||||||
LlamaServerManager.startServer(binPath, config.modelPath,port = 8080)
|
LlamaServerManager.startServer(binPath, config.modelPath,port = 8080)
|
||||||
}
|
}
|
||||||
@ -167,13 +157,16 @@ fun main() = application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 대시보드로 화면 전환
|
// 대시보드로 화면 전환
|
||||||
currentScreen = AppScreen.Dashboard
|
currentScreen = AppScreen.TradingDecision
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AppScreen.Dashboard -> {
|
AppScreen.Dashboard -> {
|
||||||
DashboardScreen()
|
DashboardScreen()
|
||||||
}
|
}
|
||||||
|
AppScreen.TradingDecision -> {
|
||||||
|
TradingDecisionLog()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import AutoTradeTable.orderNo
|
import AutoTradeTable.orderNo
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import model.AppConfig
|
import model.AppConfig
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
@ -7,6 +8,8 @@ import org.jetbrains.exposed.sql.javatime.datetime
|
|||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
|
||||||
object TradeStatus {
|
object TradeStatus {
|
||||||
@ -42,6 +45,7 @@ object ConfigTable : Table("app_config") {
|
|||||||
val max_price = double("max_price").default( 40000.0)
|
val max_price = double("max_price").default( 40000.0)
|
||||||
val min_price = double("min_price").default( 800.0)
|
val min_price = double("min_price").default( 800.0)
|
||||||
val min_purchase_score = double("min_purchase_score").default( 65.0)
|
val min_purchase_score = double("min_purchase_score").default( 65.0)
|
||||||
|
val sell_profit = double("sell_profit").default( 1.3)
|
||||||
val grade_5_buy = integer("grade_5_buy").default(0)
|
val grade_5_buy = integer("grade_5_buy").default(0)
|
||||||
val grade_5_profit = double("grade_5_profit").default(1.8)
|
val grade_5_profit = double("grade_5_profit").default(1.8)
|
||||||
val grade_4_buy = integer("grade_4_buy").default(1)
|
val grade_4_buy = integer("grade_4_buy").default(1)
|
||||||
@ -226,6 +230,7 @@ object DatabaseFactory {
|
|||||||
MAX_PRICE = it[ConfigTable.max_price],
|
MAX_PRICE = it[ConfigTable.max_price],
|
||||||
MIN_PRICE = it[ConfigTable.min_price],
|
MIN_PRICE = it[ConfigTable.min_price],
|
||||||
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
|
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
|
||||||
|
SELL_PROFIT = it[ConfigTable.sell_profit],
|
||||||
GRADE_5_BUY = it[ConfigTable.grade_5_buy],
|
GRADE_5_BUY = it[ConfigTable.grade_5_buy],
|
||||||
GRADE_5_PROFIT = it[ConfigTable.grade_5_profit],
|
GRADE_5_PROFIT = it[ConfigTable.grade_5_profit],
|
||||||
GRADE_4_BUY = it[ConfigTable.grade_4_buy],
|
GRADE_4_BUY = it[ConfigTable.grade_4_buy],
|
||||||
@ -251,9 +256,9 @@ object DatabaseFactory {
|
|||||||
it[vtsSecretKey] = config.vtsSecretKey
|
it[vtsSecretKey] = config.vtsSecretKey
|
||||||
it[realAccountNo] = config.realAccountNo
|
it[realAccountNo] = config.realAccountNo
|
||||||
it[vtsAccountNo] = config.vtsAccountNo
|
it[vtsAccountNo] = config.vtsAccountNo
|
||||||
it[ConfigTable.nAppKey] = config.nAppKey
|
it[nAppKey] = config.nAppKey
|
||||||
it[ConfigTable.nSecretKey] = config.nSecretKey
|
it[nSecretKey] = config.nSecretKey
|
||||||
it[ConfigTable.dAppKey] = config.dAppKey
|
it[dAppKey] = config.dAppKey
|
||||||
it[isSimulation] = config.isSimulation
|
it[isSimulation] = config.isSimulation
|
||||||
it[htsId] = config.htsId
|
it[htsId] = config.htsId
|
||||||
it[modelPath] = config.modelPath
|
it[modelPath] = config.modelPath
|
||||||
@ -265,6 +270,7 @@ object DatabaseFactory {
|
|||||||
it[max_price] = config.MAX_PRICE
|
it[max_price] = config.MAX_PRICE
|
||||||
it[min_price] = config.MIN_PRICE
|
it[min_price] = config.MIN_PRICE
|
||||||
it[min_purchase_score] = config.MIN_PURCHASE_SCORE
|
it[min_purchase_score] = config.MIN_PURCHASE_SCORE
|
||||||
|
it[sell_profit] = config.SELL_PROFIT
|
||||||
it[grade_5_buy] = config.GRADE_5_BUY
|
it[grade_5_buy] = config.GRADE_5_BUY
|
||||||
it[grade_5_profit] = config.GRADE_5_PROFIT
|
it[grade_5_profit] = config.GRADE_5_PROFIT
|
||||||
it[grade_4_buy] = config.GRADE_4_BUY
|
it[grade_4_buy] = config.GRADE_4_BUY
|
||||||
@ -275,7 +281,7 @@ object DatabaseFactory {
|
|||||||
it[grade_2_profit] = config.GRADE_2_PROFIT
|
it[grade_2_profit] = config.GRADE_2_PROFIT
|
||||||
it[grade_1_buy] = config.GRADE_1_BUY
|
it[grade_1_buy] = config.GRADE_1_BUY
|
||||||
it[grade_1_profit] = config.GRADE_1_PROFIT
|
it[grade_1_profit] = config.GRADE_1_PROFIT
|
||||||
it[ConfigTable.max_count] =config.MAX_COUNT
|
it[max_count] = config.MAX_COUNT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -366,3 +372,47 @@ data class AutoTradeItem(
|
|||||||
val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율
|
val profitRate: Double = 0.0, // 설정 시 사용한 목표 비율
|
||||||
val stopLossRate: Double = 0.0
|
val stopLossRate: Double = 0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
object TradingLogStore {
|
||||||
|
// UI에서 관찰할 수 있는 경량 로그 리스트
|
||||||
|
val decisionLogs = mutableStateListOf<LogEntry>()
|
||||||
|
|
||||||
|
data class LogEntry(
|
||||||
|
val time: String,
|
||||||
|
val stockName: String,
|
||||||
|
val decision: String,
|
||||||
|
val confidence: Double,
|
||||||
|
val reason: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun addLog(decision: TradingDecision) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (decisionLogs.size > 100) decisionLogs.removeAt(0)
|
||||||
|
decisionLogs.add(LogEntry(
|
||||||
|
time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
|
||||||
|
stockName = "${decision.stockName}[${decision.currentPrice}]",
|
||||||
|
decision = decision.decision ?: "HOLD",
|
||||||
|
confidence = decision.confidence,
|
||||||
|
reason = decision.reason ?: ""
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addLog(tradingDecision: TradingDecision , decision: String, log: String) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
|
||||||
|
decisionLogs.add(
|
||||||
|
LogEntry(
|
||||||
|
time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
|
||||||
|
stockName = "${tradingDecision.stockName}[${tradingDecision.currentPrice}][]",
|
||||||
|
decision = decision,
|
||||||
|
confidence = tradingDecision.confidence,
|
||||||
|
reason = log
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,10 @@ enum class ConfigIndex(val index : Int,val label : String) {
|
|||||||
MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"),
|
MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"),
|
||||||
MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"),
|
MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"),
|
||||||
MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"),
|
MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"),
|
||||||
MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "주문 최대 개수"),
|
MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "단일 종목 최대 주문 개수"),
|
||||||
|
SELL_PROFIT(MAX_COUNT_INDEX.index + 1, "보유 주식 매도 기준 수익율"),
|
||||||
|
|
||||||
GRADE_5_BUY(MAX_COUNT_INDEX.index + 1, "강력 추천 투자 매수 기준"),
|
GRADE_5_BUY(SELL_PROFIT.index + 1, "강력 추천 투자 매수 기준"),
|
||||||
GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"),
|
GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"),
|
||||||
GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"),
|
GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"),
|
||||||
GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"),
|
GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"),
|
||||||
@ -69,6 +70,8 @@ data class AppConfig(
|
|||||||
var MAX_PRICE: Double = 40000.0,
|
var MAX_PRICE: Double = 40000.0,
|
||||||
var MIN_PRICE: Double = 800.0,
|
var MIN_PRICE: Double = 800.0,
|
||||||
var MIN_PURCHASE_SCORE: Double = 65.0,
|
var MIN_PURCHASE_SCORE: Double = 65.0,
|
||||||
|
var SELL_PROFIT: Double = 1.3,
|
||||||
|
|
||||||
var GRADE_5_BUY : Int = 0,
|
var GRADE_5_BUY : Int = 0,
|
||||||
var GRADE_4_BUY : Int = 1,
|
var GRADE_4_BUY : Int = 1,
|
||||||
var GRADE_3_BUY : Int = 2,
|
var GRADE_3_BUY : Int = 2,
|
||||||
@ -97,6 +100,7 @@ data class AppConfig(
|
|||||||
ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value}
|
ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value}
|
||||||
ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value}
|
ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value}
|
||||||
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value}
|
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value}
|
||||||
|
ConfigIndex.SELL_PROFIT -> {SELL_PROFIT = value}
|
||||||
ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value}
|
ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value}
|
||||||
ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value}
|
ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value}
|
||||||
ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value}
|
ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value}
|
||||||
@ -133,6 +137,7 @@ data class AppConfig(
|
|||||||
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {
|
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {
|
||||||
MIN_PURCHASE_SCORE
|
MIN_PURCHASE_SCORE
|
||||||
}
|
}
|
||||||
|
ConfigIndex.SELL_PROFIT -> {SELL_PROFIT }
|
||||||
ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()}
|
ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()}
|
||||||
ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()}
|
ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()}
|
||||||
ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()}
|
ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()}
|
||||||
|
|||||||
@ -215,3 +215,59 @@ data class ExecutionData(
|
|||||||
val qty: String,
|
val qty: String,
|
||||||
val isFilled: Boolean
|
val isFilled: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class InvestmentGrade(
|
||||||
|
val displayName: String,
|
||||||
|
val description: String,
|
||||||
|
val shortWeight: Double = 0.0,
|
||||||
|
val midWeight: Double = 0.0,
|
||||||
|
val longWeight: Double = 0.0,
|
||||||
|
val profitGuide: ConfigIndex,
|
||||||
|
val buyGuide: ConfigIndex,
|
||||||
|
) {
|
||||||
|
LEVEL_5_STRONG_RECOMMEND(
|
||||||
|
displayName = "최상급 추천",
|
||||||
|
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
|
||||||
|
shortWeight = 1.0,
|
||||||
|
midWeight = 1.0,
|
||||||
|
longWeight = 1.0,
|
||||||
|
profitGuide = ConfigIndex.GRADE_5_PROFIT,
|
||||||
|
buyGuide = ConfigIndex.GRADE_5_BUY,
|
||||||
|
),
|
||||||
|
LEVEL_4_BALANCED_RECOMMEND(
|
||||||
|
displayName = "균형 추천",
|
||||||
|
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
|
||||||
|
shortWeight = 0.8,
|
||||||
|
midWeight = 1.0,
|
||||||
|
longWeight = 1.0,
|
||||||
|
profitGuide = ConfigIndex.GRADE_4_PROFIT,
|
||||||
|
buyGuide = ConfigIndex.GRADE_4_BUY,
|
||||||
|
),
|
||||||
|
LEVEL_3_CAUTIOUS_RECOMMEND(
|
||||||
|
displayName = "보수적 추천",
|
||||||
|
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
|
||||||
|
shortWeight = 0.6,
|
||||||
|
midWeight = 1.0,
|
||||||
|
longWeight = 1.0,
|
||||||
|
profitGuide = ConfigIndex.GRADE_3_PROFIT,
|
||||||
|
buyGuide = ConfigIndex.GRADE_3_BUY,
|
||||||
|
),
|
||||||
|
LEVEL_2_HIGH_RISK(
|
||||||
|
displayName = "고위험 추천",
|
||||||
|
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
|
||||||
|
shortWeight = 1.0,
|
||||||
|
midWeight = 0.4,
|
||||||
|
longWeight = 0.4,
|
||||||
|
profitGuide = ConfigIndex.GRADE_2_PROFIT,
|
||||||
|
buyGuide = ConfigIndex.GRADE_2_BUY,
|
||||||
|
),
|
||||||
|
LEVEL_1_SPECULATIVE(
|
||||||
|
displayName = "순수 공격적 선택",
|
||||||
|
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
|
||||||
|
shortWeight = 1.0,
|
||||||
|
midWeight = 0.2,
|
||||||
|
longWeight = 0.2,
|
||||||
|
profitGuide = ConfigIndex.GRADE_1_PROFIT,
|
||||||
|
buyGuide = ConfigIndex.GRADE_1_BUY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
|
import AutoTradeItem
|
||||||
import TradingDecision
|
import TradingDecision
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import getLlamaBinPath
|
import getLlamaBinPath
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -17,6 +19,8 @@ import kotlinx.coroutines.withTimeout
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import model.CandleData
|
import model.CandleData
|
||||||
import model.ConfigIndex
|
import model.ConfigIndex
|
||||||
|
import model.ExecutionData
|
||||||
|
import model.InvestmentGrade
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import model.RankingStock
|
import model.RankingStock
|
||||||
import model.RankingType
|
import model.RankingType
|
||||||
@ -46,9 +50,6 @@ object AutoTradingManager {
|
|||||||
private val lastTickTime = AtomicLong(System.currentTimeMillis())
|
private val lastTickTime = AtomicLong(System.currentTimeMillis())
|
||||||
private var watchdogJob: Job? = null
|
private var watchdogJob: Job? = null
|
||||||
|
|
||||||
// 설정 상수
|
|
||||||
private const val MIN_RISE_RATE = 0.1
|
|
||||||
private const val MAX_RISE_RATE = 21.0
|
|
||||||
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
|
private const val CYCLE_TIMEOUT = 30 * 60 * 1000L // 한 사이클 최대 10분
|
||||||
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
|
private const val WATCHDOG_CHECK_INTERVAL = 30 * 1000L // 30초마다 생존 확인
|
||||||
private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
|
private const val STUCK_THRESHOLD = 3 * 60 * 1000L // 5분간 반응 없으면 'Stuck'으로 판단
|
||||||
@ -58,10 +59,246 @@ object AutoTradingManager {
|
|||||||
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
|
// private val processedCodes = mutableSetOf<String>() // 중복 처리 방지용 (선택 사항)
|
||||||
private val reanalysisList = mutableListOf<RankingStock>()
|
private val reanalysisList = mutableListOf<RankingStock>()
|
||||||
private val retryCountMap = mutableMapOf<String, Int>()
|
private val retryCountMap = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean ->
|
||||||
|
if (isSuccess && completeTradingDecision != null) {
|
||||||
|
// 1. 로그 저장소에 기록 (UI에서 이걸 읽음)
|
||||||
|
TradingLogStore.addLog(completeTradingDecision)
|
||||||
|
|
||||||
|
println("🚀 [자동매수 실행] ${completeTradingDecision.stockName}")
|
||||||
|
if (completeTradingDecision != null && !completeTradingDecision.stockCode.isNullOrEmpty()) {
|
||||||
|
var basePrice = completeTradingDecision.currentPrice
|
||||||
|
var stockCode = completeTradingDecision.stockCode
|
||||||
|
println("basePrice $basePrice")
|
||||||
|
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
|
||||||
|
var maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
|
||||||
|
val buyWeight = KisSession.config.getValues(ConfigIndex.BUY_WEIGHT_INDEX)
|
||||||
|
val baseProfit = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
|
||||||
|
|
||||||
|
fun resultCheck(completeTradingDecision :TradingDecision) {
|
||||||
|
val weights = mapOf(
|
||||||
|
"short" to 0.2, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
|
||||||
|
"profit" to 0.4,
|
||||||
|
"safe" to 0.4 // 중장기 점수 비중 강화
|
||||||
|
)
|
||||||
|
|
||||||
|
val totalScore =
|
||||||
|
((completeTradingDecision.shortPossible() + append) * weights["short"]!!) +
|
||||||
|
((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) +
|
||||||
|
((completeTradingDecision.safePossible() + append) * weights["safe"]!!)
|
||||||
|
|
||||||
|
if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
|
||||||
|
var investmentGrade : InvestmentGrade = AutoTradingManager.getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
|
||||||
|
|
||||||
|
val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
|
||||||
|
println("""
|
||||||
|
사명 : ${completeTradingDecision.corpName}
|
||||||
|
신뢰도 : ${completeTradingDecision.confidence + append}
|
||||||
|
단기성 : ${completeTradingDecision.shortPossible() + append}
|
||||||
|
수익성 : ${completeTradingDecision.profitPossible()+ append}
|
||||||
|
안전성 : ${completeTradingDecision.safePossible()+ append}
|
||||||
|
${investmentGrade.displayName} : ${investmentGrade.description}
|
||||||
|
총점 : ${totalScore}
|
||||||
|
""".trimIndent())
|
||||||
|
println("🚀 [매수 진행] 토탈 스코어: ${String.format("%.1f", totalScore)} -> 종목: ${completeTradingDecision.stockCode}")
|
||||||
|
|
||||||
|
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
|
||||||
|
val gradeRate = (1.0 - (investmentGrade.ordinal * 0.1))
|
||||||
|
val maxQty = (KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX) * gradeRate).roundToInt()
|
||||||
|
maxBudget = maxBudget * gradeRate
|
||||||
|
val calculatedQty = if (basePrice > 0) {
|
||||||
|
(maxBudget / basePrice).toInt().coerceAtLeast(1)
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
// 5. 매수 실행 (계산된 finalMargin 전달)
|
||||||
|
AutoTradingManager.excuteTrade(
|
||||||
|
decision = completeTradingDecision,
|
||||||
|
orderQty = min(calculatedQty, maxQty).toString(),
|
||||||
|
profitRate1 = finalMargin,
|
||||||
|
investmentGrade = investmentGrade,
|
||||||
|
)
|
||||||
|
|
||||||
|
} else if(totalScore >= (minScore * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) {
|
||||||
|
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
|
||||||
|
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
|
||||||
|
} else {
|
||||||
|
TradingLogStore.addLog(completeTradingDecision,"HOLD","✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (completeTradingDecision?.decision) {
|
||||||
|
"BUY" -> {
|
||||||
|
append = buyWeight
|
||||||
|
TradingLogStore.addLog(completeTradingDecision,"BUY","[$stockCode] 매수 추천 resultCheck: ${completeTradingDecision?.reason}")
|
||||||
|
resultCheck(completeTradingDecision)
|
||||||
|
}
|
||||||
|
"SELL" -> println("[$stockCode] 매도: ${completeTradingDecision?.reason}")
|
||||||
|
"HOLD" -> {
|
||||||
|
append = 0.0
|
||||||
|
TradingLogStore.addLog(completeTradingDecision,"HOLD","[$stockCode] 관망 유지 resultCheck: ${completeTradingDecision?.reason}")
|
||||||
|
resultCheck(completeTradingDecision)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
append = 0.0
|
||||||
|
println("[$stockCode] ${completeTradingDecision?.decision} resultCheck: ${completeTradingDecision?.reason}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIN_CONFIDENCE = 70.0 // 최소 신뢰도
|
||||||
|
var append = 0.0
|
||||||
|
|
||||||
|
fun getInvestmentGrade(
|
||||||
|
ts: TradingDecision,
|
||||||
|
totalScore: Double,
|
||||||
|
confidence: Double
|
||||||
|
): InvestmentGrade {
|
||||||
|
// 1. 기본 조건 충족 여부
|
||||||
|
if (totalScore < 68.0 || confidence < 70.0) {
|
||||||
|
return InvestmentGrade.LEVEL_1_SPECULATIVE // 매도/관망 (추천 등급 없음)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 단기/중기/장기 패턴 기준
|
||||||
|
val ultraShort = ts.ultraShortScore
|
||||||
|
val short = ts.shortTermScore
|
||||||
|
val mid = ts.midTermScore
|
||||||
|
val long = ts.longTermScore
|
||||||
|
|
||||||
|
val shortAvg = listOf(ultraShort, short).average() // 초단기+단기
|
||||||
|
val midLongAvg = listOf(mid, long).average() // 중기+장기
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음
|
||||||
|
shortAvg >= 85.0 && midLongAvg >= 80.0 ->
|
||||||
|
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
|
||||||
|
|
||||||
|
// LEVEL_4: 중기·장기 기본 준수, 단기까지 양호
|
||||||
|
midLongAvg >= 75.0 && shortAvg >= 70.0 ->
|
||||||
|
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND else InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
|
||||||
|
|
||||||
|
// LEVEL_3: 중기·장기 기본 이상, 단기만 단기 변동성 높은 보수형
|
||||||
|
midLongAvg >= 70.0 && shortAvg in 60.0..70.0 ->
|
||||||
|
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
|
||||||
|
|
||||||
|
// LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매
|
||||||
|
shortAvg >= 75.0 && midLongAvg < 65.0 ->
|
||||||
|
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||||
|
|
||||||
|
// LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함
|
||||||
|
shortAvg >= 70.0 && midLongAvg < 55.0 ->
|
||||||
|
InvestmentGrade.LEVEL_1_SPECULATIVE
|
||||||
|
|
||||||
|
// 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립)
|
||||||
|
else ->
|
||||||
|
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun excuteTrade(decision: TradingDecision,orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
|
||||||
|
scope.launch {
|
||||||
|
var basePrice = decision.currentPrice
|
||||||
|
val tickSize = MarketUtil.getTickSize(basePrice)
|
||||||
|
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
|
||||||
|
var stockCode = decision.stockCode
|
||||||
|
var stockName = decision.stockName
|
||||||
|
val finalPrice = MarketUtil.roundToTickSize(oneTickLowerPrice.toDouble())
|
||||||
|
|
||||||
|
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
|
||||||
|
KisTradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
|
||||||
|
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
||||||
|
println("주문 성공: $realOrderNo ${stockCode} $orderQty $finalPrice")
|
||||||
|
TradingLogStore.addLog(decision,"BUY","주문 성공: $realOrderNo")
|
||||||
|
val pRate = 0.4
|
||||||
|
val sRate = -1.5
|
||||||
|
|
||||||
|
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||||
|
val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues(
|
||||||
|
ConfigIndex.PROFIT_INDEX) + tax))
|
||||||
|
|
||||||
|
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
|
||||||
|
val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0))
|
||||||
|
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
||||||
|
|
||||||
|
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||||
|
orderNo = realOrderNo,
|
||||||
|
code = stockCode,
|
||||||
|
name = stockName,
|
||||||
|
quantity = inputQty,
|
||||||
|
profitRate = effectiveProfitRate, // 보정된 수익률 저장
|
||||||
|
stopLossRate = sRate,
|
||||||
|
targetPrice = calculatedTarget,
|
||||||
|
stopLossPrice = calculatedStop,
|
||||||
|
status = "PENDING_BUY",
|
||||||
|
isDomestic = true
|
||||||
|
))
|
||||||
|
syncAndExecute(realOrderNo)
|
||||||
|
TradingLogStore.addLog(decision,"BUY","매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo")
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
println("매수 실패: ${it.message} ${stockCode} $orderQty $finalPrice")
|
||||||
|
TradingLogStore.addLog(decision,"BUY",it.message ?: "매수 실패")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val executionCache = mutableMapOf<String, ExecutionData>()
|
||||||
|
val processingIds = mutableSetOf<String>() // 주문번호 기준 잠금
|
||||||
|
suspend fun syncAndExecute(orderNo: String) {
|
||||||
|
if (processingIds.contains(orderNo)) return
|
||||||
|
processingIds.add(orderNo)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||||
|
val execData = executionCache[orderNo]
|
||||||
|
|
||||||
|
if (dbItem != null && execData != null && execData.isFilled) {
|
||||||
|
if (dbItem.status == TradeStatus.PENDING_BUY) {
|
||||||
|
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
|
||||||
|
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
|
||||||
|
|
||||||
|
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
|
||||||
|
|
||||||
|
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
|
||||||
|
|
||||||
|
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
|
||||||
|
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
|
||||||
|
|
||||||
|
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
|
||||||
|
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
|
||||||
|
|
||||||
|
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
|
||||||
|
|
||||||
|
KisTradeService.postOrder(
|
||||||
|
stockCode = dbItem.code,
|
||||||
|
qty = dbItem.quantity.toString(),
|
||||||
|
price = finalTargetPrice.toLong().toString(),
|
||||||
|
isBuy = false
|
||||||
|
).onSuccess { newSellOrderNo ->
|
||||||
|
// 익절가 업데이트 및 상태 변경
|
||||||
|
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
|
||||||
|
// (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
|
||||||
|
|
||||||
|
executionCache.remove(orderNo)
|
||||||
|
}.onFailure {
|
||||||
|
println("❌ 익절 주문 실패: ${it.message}")
|
||||||
|
}
|
||||||
|
} else if (dbItem.status == TradeStatus.SELLING) {
|
||||||
|
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
|
||||||
|
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||||
|
executionCache.remove(orderNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processingIds.remove(orderNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동 발굴 루프 시작 및 Watchdog 실행
|
* 자동 발굴 루프 시작 및 Watchdog 실행
|
||||||
*/
|
*/
|
||||||
fun startAutoDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
|
fun startAutoDiscoveryLoop() {
|
||||||
if (isRunning()) return
|
if (isRunning()) return
|
||||||
|
|
||||||
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
|
// 1. 기존 Watchdog이 있다면 제거 후 새로 시작
|
||||||
@ -72,27 +309,22 @@ object AutoTradingManager {
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) {
|
if (isRunning() && (now - lastTickTime.get() > STUCK_THRESHOLD)) {
|
||||||
println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.")
|
println("🚨 [Watchdog] 루프 멈춤 감지 (5분간 응답 없음). 강제 재시작합니다.")
|
||||||
restartLoop(tradeService, callback)
|
restartLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 메인 루프 실행
|
// 2. 메인 루프 실행
|
||||||
runDiscoveryLoop(tradeService, callback)
|
runDiscoveryLoop(KisTradeService, globalCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
|
suspend fun resumePendingSellOrders(tradeService: KisTradeService,balance : UnifiedBalance) {
|
||||||
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
|
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
|
||||||
println("resumePendingSellOrders")
|
println("resumePendingSellOrders")
|
||||||
// val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
|
|
||||||
// println("pendingSells >>> ${pendingSells.size}")
|
|
||||||
balance.holdings.forEach { holding ->
|
balance.holdings.forEach { holding ->
|
||||||
// 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치)
|
|
||||||
// val balance = tradeService.fetchIntegratedBalance().getOrNull()
|
|
||||||
// val holding = balance?.holdings?.find { it.code == item.code }
|
|
||||||
|
|
||||||
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > 0.8) {
|
if (holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() > KisSession.config.SELL_PROFIT) {
|
||||||
println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
|
println("${holding.name} - 매수 : ${holding.avgPrice} - 현재 : ${holding.currentPrice} ")
|
||||||
|
|
||||||
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
|
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
|
||||||
@ -113,8 +345,7 @@ object AutoTradingManager {
|
|||||||
println("❌ [재주문 실패] ${holding.name}: ${it.message}")
|
println("❌ [재주문 실패] ${holding.name}: ${it.message}")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리
|
|
||||||
// DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED)
|
|
||||||
}
|
}
|
||||||
delay(200) // API 호출 부하 방지
|
delay(200) // API 호출 부하 방지
|
||||||
}
|
}
|
||||||
@ -167,17 +398,11 @@ object AutoTradingManager {
|
|||||||
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
now = LocalTime.now(ZoneId.of("Asia/Seoul"))
|
||||||
currentTimeMillis = System.currentTimeMillis()
|
currentTimeMillis = System.currentTimeMillis()
|
||||||
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
|
lastTickTime.set(System.currentTimeMillis()) // 생존 신고
|
||||||
// if (now.minute % 5 == 0) {
|
|
||||||
// SystemSleepPreventer.sleepDisplay()
|
|
||||||
// } else {
|
|
||||||
// SystemSleepPreventer.wakeDisplay()
|
|
||||||
// }
|
|
||||||
when {
|
when {
|
||||||
//장중
|
//장중
|
||||||
now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> {
|
now.isBefore(LocalTime.of(16, 0)) && now.isAfter(LocalTime.of(8, 50)) -> {
|
||||||
waitTime = 0.2
|
waitTime = 0.2
|
||||||
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
|
if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) {
|
||||||
// 토큰 중 하나라도 만료 5분 전이거나 비어있다면 다시 준비 상태로 전환
|
|
||||||
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
|
if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) {
|
||||||
if (isSystemReadyToday) {
|
if (isSystemReadyToday) {
|
||||||
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
|
println("⚠️ [System] 토큰 만료 감지. 재발급 프로세스를 가동합니다.")
|
||||||
@ -231,18 +456,11 @@ object AutoTradingManager {
|
|||||||
println("미확인 데이터 ${remainingCandidates.size}")
|
println("미확인 데이터 ${remainingCandidates.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// [프로세스 3] 종목별 순회 분석
|
|
||||||
var totalCount = remainingCandidates.size
|
var totalCount = remainingCandidates.size
|
||||||
println("후보군 조건 충족 총 개수 : ${totalCount}")
|
println("후보군 조건 충족 총 개수 : ${totalCount}")
|
||||||
val iterator = remainingCandidates.iterator()
|
val iterator = remainingCandidates.iterator()
|
||||||
|
|
||||||
while (iterator.hasNext()) {
|
while (iterator.hasNext()) {
|
||||||
if (now.minute % 2 == 0) {
|
|
||||||
// SystemSleepPreventer.sleepDisplay()
|
|
||||||
} else {
|
|
||||||
// SystemSleepPreventer.wakeDisplay()
|
|
||||||
}
|
|
||||||
totalCount--
|
totalCount--
|
||||||
val stock = iterator.next()
|
val stock = iterator.next()
|
||||||
try {
|
try {
|
||||||
@ -411,9 +629,9 @@ object AutoTradingManager {
|
|||||||
).awaitAll().flatten()
|
).awaitAll().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restartLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
|
private fun restartLoop() {
|
||||||
discoveryJob?.cancel()
|
discoveryJob?.cancel()
|
||||||
startAutoDiscoveryLoop(tradeService, callback)
|
startAutoDiscoveryLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun waitForNextCycle(minutes: Double) {
|
private suspend fun waitForNextCycle(minutes: Double) {
|
||||||
@ -460,10 +678,10 @@ object AutoTradingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkAndRestart(tradeService: KisTradeService, callback: TradingDecisionCallback) {
|
fun checkAndRestart() {
|
||||||
if (!isRunning()) {
|
if (!isRunning()) {
|
||||||
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
|
println("⚠️ [Watchdog] 자동 발굴 루프가 중단된 것을 감지했습니다. 재시작을 시도합니다...")
|
||||||
startAutoDiscoveryLoop(tradeService, callback)
|
startAutoDiscoveryLoop()
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,14 +58,6 @@ fun DashboardScreen() {
|
|||||||
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||||
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
|
||||||
|
|
||||||
fun setupAutoTradingWatchdog(tradeService: KisTradeService, callback: TradingDecisionCallback) {
|
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
// while (true) {
|
|
||||||
// delay(60000) // 1분마다 체크
|
|
||||||
AutoTradingManager.checkAndRestart(tradeService, callback)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var callback = object : TradingDecisionCallback {
|
var callback = object : TradingDecisionCallback {
|
||||||
@ -91,11 +83,6 @@ fun DashboardScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// 화면이 완전히 그려지고 안정화될 때까지 1초 대기
|
|
||||||
delay(1000)
|
|
||||||
AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 리소스 정리는 여전히 DisposableEffect에서 수행
|
// 리소스 정리는 여전히 DisposableEffect에서 수행
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@ -108,13 +95,13 @@ fun DashboardScreen() {
|
|||||||
var refreshTrigger by remember { mutableStateOf(0) }
|
var refreshTrigger by remember { mutableStateOf(0) }
|
||||||
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
|
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
|
||||||
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
|
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
|
||||||
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
|
|
||||||
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
|
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
|
||||||
|
|
||||||
LaunchedEffect(refreshTrigger) {
|
LaunchedEffect(refreshTrigger) {
|
||||||
// setupAutoTradingWatchdog(tradeService,callback)
|
// setupAutoTradingWatchdog(tradeService,callback)
|
||||||
}
|
}
|
||||||
|
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
|
||||||
suspend fun syncAndExecute(orderNo: String) {
|
suspend fun syncAndExecute(orderNo: String) {
|
||||||
if (processingIds.contains(orderNo)) return
|
if (processingIds.contains(orderNo)) return
|
||||||
processingIds.add(orderNo)
|
processingIds.add(orderNo)
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import androidx.compose.ui.unit.min
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import model.ConfigIndex
|
import model.ConfigIndex
|
||||||
|
import model.InvestmentGrade
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import model.RankingStock
|
import model.RankingStock
|
||||||
import network.KisTradeService
|
import network.KisTradeService
|
||||||
@ -30,61 +31,7 @@ import util.MarketUtil
|
|||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
enum class InvestmentGrade(
|
|
||||||
val displayName: String,
|
|
||||||
val description: String,
|
|
||||||
val shortWeight: Double = 0.0,
|
|
||||||
val midWeight: Double = 0.0,
|
|
||||||
val longWeight: Double = 0.0,
|
|
||||||
val profitGuide: ConfigIndex,
|
|
||||||
val buyGuide: ConfigIndex,
|
|
||||||
) {
|
|
||||||
LEVEL_5_STRONG_RECOMMEND(
|
|
||||||
displayName = "최상급 추천",
|
|
||||||
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
|
|
||||||
shortWeight = 1.0,
|
|
||||||
midWeight = 1.0,
|
|
||||||
longWeight = 1.0,
|
|
||||||
profitGuide = ConfigIndex.GRADE_5_PROFIT,
|
|
||||||
buyGuide = ConfigIndex.GRADE_5_BUY,
|
|
||||||
),
|
|
||||||
LEVEL_4_BALANCED_RECOMMEND(
|
|
||||||
displayName = "균형 추천",
|
|
||||||
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
|
|
||||||
shortWeight = 0.8,
|
|
||||||
midWeight = 1.0,
|
|
||||||
longWeight = 1.0,
|
|
||||||
profitGuide = ConfigIndex.GRADE_4_PROFIT,
|
|
||||||
buyGuide = ConfigIndex.GRADE_4_BUY,
|
|
||||||
),
|
|
||||||
LEVEL_3_CAUTIOUS_RECOMMEND(
|
|
||||||
displayName = "보수적 추천",
|
|
||||||
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
|
|
||||||
shortWeight = 0.6,
|
|
||||||
midWeight = 1.0,
|
|
||||||
longWeight = 1.0,
|
|
||||||
profitGuide = ConfigIndex.GRADE_3_PROFIT,
|
|
||||||
buyGuide = ConfigIndex.GRADE_3_BUY,
|
|
||||||
),
|
|
||||||
LEVEL_2_HIGH_RISK(
|
|
||||||
displayName = "고위험 추천",
|
|
||||||
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
|
|
||||||
shortWeight = 1.0,
|
|
||||||
midWeight = 0.4,
|
|
||||||
longWeight = 0.4,
|
|
||||||
profitGuide = ConfigIndex.GRADE_2_PROFIT,
|
|
||||||
buyGuide = ConfigIndex.GRADE_2_BUY,
|
|
||||||
),
|
|
||||||
LEVEL_1_SPECULATIVE(
|
|
||||||
displayName = "순수 공격적 선택",
|
|
||||||
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
|
|
||||||
shortWeight = 1.0,
|
|
||||||
midWeight = 0.2,
|
|
||||||
longWeight = 0.2,
|
|
||||||
profitGuide = ConfigIndex.GRADE_1_PROFIT,
|
|
||||||
buyGuide = ConfigIndex.GRADE_1_BUY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -316,7 +263,7 @@ fun IntegratedOrderSection(
|
|||||||
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
|
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
|
||||||
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
|
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
|
||||||
} else {
|
} else {
|
||||||
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달")
|
println("✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (completeTradingDecision?.decision) {
|
when (completeTradingDecision?.decision) {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import io.ktor.client.plugins.logging.LogLevel
|
|||||||
import io.ktor.client.plugins.logging.Logger
|
import io.ktor.client.plugins.logging.Logger
|
||||||
import io.ktor.client.plugins.logging.Logging
|
import io.ktor.client.plugins.logging.Logging
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import model.AppConfig
|
import model.AppConfig
|
||||||
@ -53,17 +54,22 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) {
|
||||||
item {
|
item {
|
||||||
Text("거래 방식 선택", style = MaterialTheme.typography.h6)
|
Row(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
|
||||||
|
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
|
||||||
|
){
|
||||||
|
Text("투자 방식", style = MaterialTheme.typography.subtitle1)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) })
|
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) })
|
||||||
Text("실전투자")
|
Text("실전")
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
RadioButton(selected = config.isSimulation, onClick = { config = config.copy() })
|
RadioButton(selected = config.isSimulation, onClick = { config = config.copy() })
|
||||||
Text("모의투자")
|
Text("모의")
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(vertical = 12.dp))
|
|
||||||
|
Divider(Modifier.padding(vertical = 10.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = config.htsId,
|
value = config.htsId,
|
||||||
onValueChange = { config = config.copy(htsId = it,) },
|
onValueChange = { config = config.copy(htsId = it,) },
|
||||||
@ -73,94 +79,126 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
|
|||||||
)
|
)
|
||||||
// 실전 3종 입력
|
// 실전 3종 입력
|
||||||
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
|
||||||
OutlinedTextField(value = config.realAccountNo, onValueChange = {
|
Row(
|
||||||
config = config.copy(realAccountNo = it,)
|
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
|
||||||
if(it.length >= 8) checkAndLoadConfig(it, true)
|
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
|
||||||
}, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth())
|
) {
|
||||||
OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it,) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(
|
||||||
|
value = config.realAccountNo, onValueChange = {
|
||||||
|
config = config.copy(realAccountNo = it,)
|
||||||
|
if (it.length >= 8) checkAndLoadConfig(it, true)
|
||||||
|
}, label = { Text("실전 계좌번호") }, modifier = Modifier.weight(0.5f))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = config.realAppKey,
|
||||||
|
onValueChange = { config = config.copy(realAppKey = it,) },
|
||||||
|
label = { Text("실전 App Key") },
|
||||||
|
modifier = Modifier.weight(0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// 모의 3종 입력
|
// 모의 3종 입력
|
||||||
Text("모의투자 정보", fontWeight = FontWeight.Bold)
|
Text("모의투자 정보", fontWeight = FontWeight.Bold)
|
||||||
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
|
Row(
|
||||||
config = config.copy(vtsAccountNo = it,)
|
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
|
||||||
if(it.length >= 8) checkAndLoadConfig(it, false)
|
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
|
||||||
}, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth())
|
) {
|
||||||
OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it,) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
|
||||||
|
config = config.copy(vtsAccountNo = it,)
|
||||||
|
if (it.length >= 8) checkAndLoadConfig(it, false)
|
||||||
|
}, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = config.vtsAppKey,
|
||||||
|
onValueChange = { config = config.copy(vtsAppKey = it,) },
|
||||||
|
label = { Text("모의 App Key") },
|
||||||
|
modifier = Modifier.weight(0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||||
|
|
||||||
Divider(Modifier.padding(vertical = 16.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold)
|
||||||
OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth())
|
||||||
OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||||
OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
|
||||||
// AI 모델 경로 및 드래그 앤 드롭
|
Spacer(Modifier.height(10.dp))
|
||||||
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
|
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
|
||||||
.onExternalDrag(onDrop = { state ->
|
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
|
||||||
val data = state.dragData
|
){
|
||||||
if (data is DragData.FilesList) {
|
Box(
|
||||||
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
|
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
||||||
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,)
|
.onExternalDrag(onDrop = { state ->
|
||||||
}
|
val data = state.dragData
|
||||||
}),
|
if (data is DragData.FilesList) {
|
||||||
contentAlignment = Alignment.Center
|
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
|
||||||
) {
|
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,)
|
||||||
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
|
}
|
||||||
|
}),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
||||||
|
.onExternalDrag(onDrop = { state ->
|
||||||
|
val data = state.dragData
|
||||||
|
if (data is DragData.FilesList) {
|
||||||
|
val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:")
|
||||||
|
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Spacer(Modifier.height(10.dp))
|
||||||
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
|
|
||||||
.onExternalDrag(onDrop = { state ->
|
|
||||||
val data = state.dragData
|
|
||||||
if (data is DragData.FilesList) {
|
|
||||||
val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:")
|
|
||||||
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// isLoading = true
|
var retryCount = 0
|
||||||
// 1. KisSession.config 업데이트 및 DB 저장
|
val maxRetries = 3
|
||||||
KisSession.config = config
|
val retryDelay = 90_000L // 1분 30초
|
||||||
DatabaseFactory.saveConfig(config)
|
var isAuthCompleted = false
|
||||||
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
|
|
||||||
install(Logging) {
|
|
||||||
logger = Logger.DEFAULT
|
|
||||||
level = LogLevel.BODY
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val authService = KisAuthService
|
|
||||||
val tradeService = KisTradeService
|
|
||||||
val authSuccess = authService.refreshAllTokens()
|
|
||||||
val wsKeySuccess = tradeService.refreshWebsocketKey()
|
|
||||||
|
|
||||||
if (authSuccess && wsKeySuccess) {
|
while (retryCount <= maxRetries && !isAuthCompleted) {
|
||||||
statusMessage = "✅ 인증 성공! LLM 시작 중..."
|
if (retryCount > 0) {
|
||||||
onAuthSuccess()
|
statusMessage = "⏳ 인증 재시도 중... (${retryCount}/${maxRetries}) - 1분 30초 후 재시작"
|
||||||
} else {
|
delay(retryDelay)
|
||||||
statusMessage = "❌ 인증 실패. 키 정보를 확인하세요."
|
}
|
||||||
|
|
||||||
|
// 1. 설정값 저장
|
||||||
|
KisSession.config = config
|
||||||
|
DatabaseFactory.saveConfig(config)
|
||||||
|
|
||||||
|
// 2. 법인코드 업데이트
|
||||||
|
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 토큰 및 웹소켓 키 갱신
|
||||||
|
val authSuccess = KisAuthService.refreshAllTokens()
|
||||||
|
val wsKeySuccess = KisTradeService.refreshWebsocketKey()
|
||||||
|
|
||||||
|
if (authSuccess && wsKeySuccess) {
|
||||||
|
statusMessage = "✅ 인증 성공! LLM 시작 중..."
|
||||||
|
isAuthCompleted = true
|
||||||
|
onAuthSuccess()
|
||||||
|
} else {
|
||||||
|
retryCount++
|
||||||
|
if (retryCount > maxRetries) {
|
||||||
|
statusMessage = "❌ 인증 실패. 3회 재시도 후 중단되었습니다. 키 정보를 확인하세요."
|
||||||
|
} else {
|
||||||
|
statusMessage = "⚠️ 인증 실패. 잠시 후 자동으로 다시 시도합니다. (시도 $retryCount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// isLoading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { Text("설정 저장 및 실행") }
|
) { Text("설정 저장 및 실행") }
|
||||||
|
|||||||
227
src/main/kotlin/ui/TradingDecisionLog.kt
Normal file
227
src/main/kotlin/ui/TradingDecisionLog.kt
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
|
||||||
|
import AutoTradeItem
|
||||||
|
import TradingDecision
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import model.ConfigIndex
|
||||||
|
import model.KisSession
|
||||||
|
import service.TechnicalAnalyzer
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TradingDecisionLog() {
|
||||||
|
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||||
|
Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) {
|
||||||
|
Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6)
|
||||||
|
Divider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
LazyColumn(reverseLayout = true) { // 최신 로그가 위로 오게 함
|
||||||
|
items(TradingLogStore.decisionLogs) { log ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
elevation = 2.dp
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("${log.time} - ${log.stockName}", fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
text = log.decision,
|
||||||
|
color = if (log.decision == "BUY") Color.Red else Color.Gray,
|
||||||
|
fontWeight = FontWeight.ExtraBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text("신뢰도: ${log.confidence}%", fontSize = 11.sp)
|
||||||
|
Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2), // 2열 병렬 배치
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().fillMaxHeight().background(Color.White)
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||||
|
Text(
|
||||||
|
"💰 거래 기본 설정",
|
||||||
|
style = MaterialTheme.typography.subtitle2,
|
||||||
|
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var defaults = arrayOf(
|
||||||
|
ConfigIndex.TAX_INDEX,
|
||||||
|
ConfigIndex.PROFIT_INDEX,
|
||||||
|
ConfigIndex.BUY_WEIGHT_INDEX,
|
||||||
|
ConfigIndex.MAX_BUDGET_INDEX,
|
||||||
|
ConfigIndex.MAX_PRICE_INDEX,
|
||||||
|
ConfigIndex.MIN_PRICE_INDEX,
|
||||||
|
ConfigIndex.MIN_PURCHASE_SCORE_INDEX,
|
||||||
|
ConfigIndex.SELL_PROFIT,
|
||||||
|
ConfigIndex.MAX_COUNT_INDEX,
|
||||||
|
)
|
||||||
|
items(defaults.size) { index ->
|
||||||
|
val configKey = defaults.get(index)
|
||||||
|
|
||||||
|
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
|
||||||
|
var localText by remember(configKey) {
|
||||||
|
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 로직을 공통 함수로 분리
|
||||||
|
val saveAction = {
|
||||||
|
var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||||
|
if (configKey.label.contains("PROFIT")) {
|
||||||
|
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
|
||||||
|
}
|
||||||
|
KisSession.config.setValues(configKey, newValue)
|
||||||
|
DatabaseFactory.saveConfig(KisSession.config)
|
||||||
|
println("💾 저장됨: ${configKey.label} = $newValue")
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = if (configKey.label.contains("PROFIT")) {
|
||||||
|
"${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}"
|
||||||
|
} else {
|
||||||
|
localText
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { localText = it }, // 화면에는 즉시 반영
|
||||||
|
label = { Text(configKey.label) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
// 2. 포커스를 잃었을 때 저장
|
||||||
|
if (!focusState.isFocused) {
|
||||||
|
saveAction()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardType = KeyboardType.Decimal
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
// 3. 엔터(Done) 키를 눌렀을 때 저장
|
||||||
|
onDone = {
|
||||||
|
saveAction()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||||
|
Text(
|
||||||
|
"💰매수 정책 및 기대 수익률",
|
||||||
|
style = MaterialTheme.typography.subtitle2,
|
||||||
|
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var defaults2 = arrayOf(
|
||||||
|
arrayOf(ConfigIndex.GRADE_5_BUY,
|
||||||
|
ConfigIndex.GRADE_5_PROFIT,),
|
||||||
|
arrayOf(ConfigIndex.GRADE_4_BUY,
|
||||||
|
ConfigIndex.GRADE_4_PROFIT,),
|
||||||
|
arrayOf(ConfigIndex.GRADE_3_BUY,
|
||||||
|
ConfigIndex.GRADE_3_PROFIT,),
|
||||||
|
arrayOf(ConfigIndex.GRADE_2_BUY,
|
||||||
|
ConfigIndex.GRADE_2_PROFIT,),
|
||||||
|
arrayOf(ConfigIndex.GRADE_1_BUY,
|
||||||
|
ConfigIndex.GRADE_1_PROFIT,),
|
||||||
|
)
|
||||||
|
for (items in defaults2) {
|
||||||
|
val common = findLongestCommonSubstring(items.first().label,items.last().label)
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
|
||||||
|
Text(
|
||||||
|
common,
|
||||||
|
style = MaterialTheme.typography.body1,
|
||||||
|
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(items.size) { index ->
|
||||||
|
val configKey = items.get(index)
|
||||||
|
|
||||||
|
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
|
||||||
|
var localText by remember(configKey) {
|
||||||
|
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelText by remember(configKey) {
|
||||||
|
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
val saveAction = {
|
||||||
|
var newValue = localText.toDoubleOrNull() ?: 0.0
|
||||||
|
//
|
||||||
|
KisSession.config.setValues(configKey, newValue)
|
||||||
|
DatabaseFactory.saveConfig(KisSession.config)
|
||||||
|
println("💾 저장됨: ${configKey.label} = $newValue")
|
||||||
|
labelText = if (configKey.name.contains("PROFIT")) {
|
||||||
|
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
|
||||||
|
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
|
||||||
|
ConfigIndex.TAX_INDEX)}"
|
||||||
|
} else {
|
||||||
|
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labelText = if (configKey.name.contains("PROFIT")) {
|
||||||
|
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
|
||||||
|
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
|
||||||
|
ConfigIndex.TAX_INDEX)} "
|
||||||
|
} else {
|
||||||
|
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = localText,
|
||||||
|
onValueChange = { localText = it }, // 화면에는 즉시 반영
|
||||||
|
label = { Text(labelText) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
// 2. 포커스를 잃었을 때 저장
|
||||||
|
if (!focusState.isFocused) {
|
||||||
|
saveAction()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardType = KeyboardType.Decimal
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
// 3. 엔터(Done) 키를 눌렀을 때 저장
|
||||||
|
onDone = {
|
||||||
|
saveAction()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user