This commit is contained in:
lunaticbum 2026-02-19 15:47:31 +09:00
parent df124612ab
commit c4f58f159a
11 changed files with 609 additions and 113 deletions

View File

@ -1,3 +1,14 @@
import ConfigTable.grade_1_buy
import ConfigTable.grade_1_profit
import ConfigTable.grade_2_buy
import ConfigTable.grade_2_profit
import ConfigTable.grade_3_buy
import ConfigTable.grade_3_profit
import ConfigTable.grade_4_buy
import ConfigTable.grade_4_profit
import ConfigTable.grade_5_buy
import ConfigTable.grade_5_profit
import ConfigTable.max_count
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@ -82,7 +93,25 @@ fun main() = application {
isSimulation = it[ConfigTable.isSimulation],
htsId = it[ConfigTable.htsId],
modelPath = it[ConfigTable.modelPath],
embedModelPath = it[ConfigTable.embedModelPath]
embedModelPath = it[ConfigTable.embedModelPath],
FEES_AND_TAXRATE = it[ConfigTable.fees_and_taxrate],
MINIMUM_NET_PROFIT = it[ConfigTable.minimum_net_profit],
BUY_WEIGHT = it[ConfigTable.buy_weight],
MAX_BUDGET = it[ConfigTable.max_budget],
MAX_PRICE = it[ConfigTable.max_price],
MIN_PRICE = it[ConfigTable.min_price],
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
GRADE_5_BUY = it[grade_5_buy],
GRADE_5_PROFIT = it[grade_5_profit],
GRADE_4_BUY = it[grade_4_buy],
GRADE_4_PROFIT = it[grade_4_profit],
GRADE_3_BUY = it[grade_3_buy],
GRADE_3_PROFIT = it[grade_3_profit],
GRADE_2_BUY = it[grade_2_buy],
GRADE_2_PROFIT = it[grade_2_profit],
GRADE_1_BUY = it[grade_1_buy],
GRADE_1_PROFIT = it[grade_1_profit],
MAX_COUNT = it[max_count],
)
}
}

View File

@ -30,6 +30,24 @@ object ConfigTable : Table("app_config") {
val modelPath = varchar("model_path", 512).default("")
val embedModelPath = varchar("embed_model_path", 512).default("")
val htsId = varchar("hts_id", 50).default("") // HTS ID 컬럼 추가
val fees_and_taxrate = double("fees_and_taxrate").default( 0.33)
val minimum_net_profit = double("minimum_net_profit").default( 0.8)
val buy_weight = double("buy_weight").default( 2.0)
val max_budget = double("max_budget").default( 80000.0)
val max_price = double("max_price").default( 40000.0)
val min_price = double("min_price").default( 800.0)
val min_purchase_score = double("min_purchase_score").default( 65.0)
val grade_5_buy = integer("grade_5_buy").default(0)
val grade_5_profit = double("grade_5_profit").default(1.8)
val grade_4_buy = integer("grade_4_buy").default(1)
val grade_4_profit = double("grade_4_profit").default(1.3)
val grade_3_buy = integer("grade_3_buy").default(1)
val grade_3_profit = double("grade_3_profit").default(0.9)
val grade_2_buy = integer("grade_2_buy").default(2)
val grade_2_profit = double("grade_2_profit").default(0.7)
val grade_1_buy = integer("grade_1_buy").default(3)
val grade_1_profit = double("grade_1_profit").default(0.5)
val max_count = integer("max_count").default(20)
override val primaryKey = PrimaryKey(id)
}
@ -159,7 +177,7 @@ object DatabaseFactory {
stopLossPrice = it[AutoTradeTable.stopLossPrice],
orderNo = it[AutoTradeTable.orderNo],
status = it[AutoTradeTable.status],
isDomestic = it[AutoTradeTable.isDomestic]
isDomestic = it[AutoTradeTable.isDomestic],
)
// --- 기존 설정 및 로그 관련 함수 ---
@ -189,10 +207,28 @@ object DatabaseFactory {
vtsAppKey = it[ConfigTable.vtsAppKey],
vtsSecretKey = it[ConfigTable.vtsSecretKey],
vtsAccountNo = it[ConfigTable.vtsAccountNo],
isSimulation = it[ConfigTable.isSimulation],
htsId = it[ConfigTable.htsId], // htsId 로드
htsId = it[ConfigTable.htsId],
isSimulation = it[ConfigTable.isSimulation], // htsId 로드
modelPath = it[ConfigTable.modelPath],
embedModelPath = it[ConfigTable.embedModelPath],
FEES_AND_TAXRATE = it[ConfigTable.fees_and_taxrate],
MINIMUM_NET_PROFIT = it[ConfigTable.minimum_net_profit],
BUY_WEIGHT = it[ConfigTable.buy_weight],
MAX_BUDGET = it[ConfigTable.max_budget],
MAX_PRICE = it[ConfigTable.max_price],
MIN_PRICE = it[ConfigTable.min_price],
MIN_PURCHASE_SCORE = it[ConfigTable.min_purchase_score],
GRADE_5_BUY = it[ConfigTable.grade_5_buy],
GRADE_5_PROFIT = it[ConfigTable.grade_5_profit],
GRADE_4_BUY = it[ConfigTable.grade_4_buy],
GRADE_4_PROFIT = it[ConfigTable.grade_4_profit],
GRADE_3_BUY = it[ConfigTable.grade_3_buy],
GRADE_3_PROFIT = it[ConfigTable.grade_3_profit],
GRADE_2_BUY = it[ConfigTable.grade_2_buy],
GRADE_2_PROFIT = it[ConfigTable.grade_2_profit],
GRADE_1_BUY = it[ConfigTable.grade_1_buy],
GRADE_1_PROFIT = it[ConfigTable.grade_1_profit],
MAX_COUNT = it[ConfigTable.max_count],
)
}
}
@ -211,6 +247,24 @@ object DatabaseFactory {
it[htsId] = config.htsId
it[modelPath] = config.modelPath
it[embedModelPath] = config.embedModelPath
it[fees_and_taxrate] = config.FEES_AND_TAXRATE
it[minimum_net_profit] = config.MINIMUM_NET_PROFIT
it[buy_weight] = config.BUY_WEIGHT
it[max_budget] = config.MAX_BUDGET
it[max_price] = config.MAX_PRICE
it[min_price] = config.MIN_PRICE
it[min_purchase_score] = config.MIN_PURCHASE_SCORE
it[grade_5_buy] = config.GRADE_5_BUY
it[grade_5_profit] = config.GRADE_5_PROFIT
it[grade_4_buy] = config.GRADE_4_BUY
it[grade_4_profit] = config.GRADE_4_PROFIT
it[grade_3_buy] = config.GRADE_3_BUY
it[grade_3_profit] = config.GRADE_3_PROFIT
it[grade_2_buy] = config.GRADE_2_BUY
it[grade_2_profit] = config.GRADE_2_PROFIT
it[grade_1_buy] = config.GRADE_1_BUY
it[grade_1_profit] = config.GRADE_1_PROFIT
it[ConfigTable.max_count] =config.MAX_COUNT
}
}
}

View File

@ -2,13 +2,36 @@ package model
import java.time.LocalDateTime
const val feesAndTaxRate = 0.33
const val minimumNetProfit = 0.8
const val buyWeight = 2.0
val MAX_BUDGET = 80000.0
val MAX_PRICE = 40000
val MIN_PRICE = 800
val MIN_PURCHASE_SCORE = 65.0
enum class ConfigIndex(val index : Int,val label : String) {
TAX_INDEX(0,"세금 및 수수료"),
PROFIT_INDEX(TAX_INDEX.index + 1 , "기준 수익률"),
BUY_WEIGHT_INDEX(PROFIT_INDEX.index + 1, "매수 가중치"),
MAX_BUDGET_INDEX(BUY_WEIGHT_INDEX.index + 1, "종목 당 최대 투자금"),
MAX_PRICE_INDEX(MAX_BUDGET_INDEX.index + 1 , "단일 종목 주당 최대 금액"),
MIN_PRICE_INDEX(MAX_PRICE_INDEX.index + 1, "단일 종목 주당 최소 금액"),
MIN_PURCHASE_SCORE_INDEX(MIN_PRICE_INDEX.index + 1, "주문 최소 기준 점수"),
MAX_COUNT_INDEX(MIN_PURCHASE_SCORE_INDEX.index + 1, "주문 최대 개수"),
GRADE_5_BUY(MAX_COUNT_INDEX.index + 1, "강력 추천 투자 매수 기준"),
GRADE_4_BUY(GRADE_5_BUY.index + 1, "안정적 투자 매수 기준"),
GRADE_3_BUY(GRADE_4_BUY.index + 1, "보수적 투자 매수 기준"),
GRADE_2_BUY(GRADE_3_BUY.index + 10, "하이리스크,리턴 매수 기준"),
GRADE_1_BUY(GRADE_2_BUY.index + 1, "공격적초단기 매수 기준"),
GRADE_5_PROFIT(GRADE_1_BUY.index + 1, "강력 추천 투자 목표 수익 비율"),
GRADE_4_PROFIT(GRADE_5_PROFIT.index + 1, "안정적 투자 목표 수익 비율"),
GRADE_3_PROFIT(GRADE_4_PROFIT.index + 1, "보수적 투자 목표 수익 비율"),
GRADE_2_PROFIT(GRADE_3_PROFIT.index + 1, "하이리스크,리턴 목표 수익 비율"),
GRADE_1_PROFIT(GRADE_2_PROFIT.index + 1, "공격적초단기 목표 수익 비율");
companion object {
fun get(index : Int) = ConfigIndex.entries[index]
}
}
data class AppConfig(
// [DB 저장 데이터]
// 실전 3종
@ -33,12 +56,91 @@ data class AppConfig(
var websocketToken: String = "",
val isSimulation: Boolean = true,
val modelPath: String = "",
val embedModelPath: String = "") {
val embedModelPath: String = "",
var FEES_AND_TAXRATE: Double = 0.33,
var MINIMUM_NET_PROFIT: Double = 0.8,
var BUY_WEIGHT: Double = 2.0,
var MAX_BUDGET: Double = 80000.0,
var MAX_PRICE: Double = 40000.0,
var MIN_PRICE: Double = 800.0,
var MIN_PURCHASE_SCORE: Double = 65.0,
var GRADE_5_BUY : Int = 0,
var GRADE_4_BUY : Int = 1,
var GRADE_3_BUY : Int = 2,
var GRADE_2_BUY : Int = 2,
var GRADE_1_BUY : Int = 3,
var GRADE_5_PROFIT : Double = 1.8,
var GRADE_4_PROFIT : Double = 1.3,
var GRADE_3_PROFIT : Double = 0.9,
var GRADE_2_PROFIT : Double = 0.7,
var GRADE_1_PROFIT : Double = 0.5,
var MAX_COUNT : Int = 20,
) {
val accountNo : String
get() {
return if (isSimulation) vtsAccountNo else realAccountNo
}
fun setValues(index :ConfigIndex , value : Double) {
when (index) {
ConfigIndex.TAX_INDEX -> {FEES_AND_TAXRATE = value}
ConfigIndex.PROFIT_INDEX -> {MINIMUM_NET_PROFIT = value}
ConfigIndex.MAX_PRICE_INDEX -> {MAX_PRICE = value}
ConfigIndex.MIN_PRICE_INDEX -> {MIN_PRICE = value}
ConfigIndex.BUY_WEIGHT_INDEX -> {BUY_WEIGHT = value}
ConfigIndex.MAX_BUDGET_INDEX -> {MAX_BUDGET = value}
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {MIN_PURCHASE_SCORE = value}
ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT = value}
ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT = value}
ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT = value}
ConfigIndex.GRADE_2_PROFIT -> {GRADE_2_PROFIT = value}
ConfigIndex.GRADE_1_PROFIT -> {GRADE_1_PROFIT = value}
ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY = value.toInt()}
ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY = value.toInt()}
ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY = value.toInt()}
ConfigIndex.GRADE_2_BUY -> {GRADE_2_BUY = value.toInt()}
ConfigIndex.GRADE_1_BUY -> {GRADE_1_BUY = value.toInt()}
ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT = value.toInt()}
}
}
fun getValues(index :ConfigIndex) : Double {
return when (index) {
ConfigIndex.TAX_INDEX -> {
FEES_AND_TAXRATE
}
ConfigIndex.PROFIT_INDEX -> {
MINIMUM_NET_PROFIT
}
ConfigIndex.MAX_PRICE_INDEX -> {
MAX_PRICE
}
ConfigIndex.MIN_PRICE_INDEX -> {
MIN_PRICE
}
ConfigIndex.BUY_WEIGHT_INDEX -> {
BUY_WEIGHT
}
ConfigIndex.MAX_BUDGET_INDEX -> {
MAX_BUDGET
}
ConfigIndex.MIN_PURCHASE_SCORE_INDEX -> {
MIN_PURCHASE_SCORE
}
ConfigIndex.GRADE_5_BUY -> {GRADE_5_BUY.toDouble()}
ConfigIndex.GRADE_4_BUY -> {GRADE_4_BUY.toDouble()}
ConfigIndex.GRADE_3_BUY -> {GRADE_3_BUY.toDouble()}
ConfigIndex.GRADE_2_BUY -> {GRADE_2_BUY.toDouble()}
ConfigIndex.GRADE_1_BUY -> {GRADE_1_BUY.toDouble()}
ConfigIndex.GRADE_5_PROFIT -> {GRADE_5_PROFIT}
ConfigIndex.GRADE_4_PROFIT -> {GRADE_4_PROFIT}
ConfigIndex.GRADE_3_PROFIT -> {GRADE_3_PROFIT}
ConfigIndex.GRADE_2_PROFIT -> {GRADE_2_PROFIT}
ConfigIndex.GRADE_1_PROFIT -> {GRADE_1_PROFIT}
ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()}
}
}
}

View File

@ -43,7 +43,6 @@ class KisAuthService {
*/
suspend fun refreshAllTokens(): Boolean = coroutineScope {
val config = KisSession.config
println("refreshAllTokens")
// 1. 실전 시세용 토큰 발급 (Market Token)
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
@ -75,7 +74,6 @@ class KisAuthService {
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
return try {
println("fetchAccessToken")
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
contentType(ContentType.Application.Json)
setBody(TokenRequest("client_credentials", appKey, secretKey))

View File

@ -21,6 +21,7 @@ import model.StockBalanceResponse
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@ -68,7 +69,11 @@ object KisTradeService {
code = it.pdno, name = it.prdt_name, quantity = it.hldg_qty,
avgPrice = it.pchs_avg_pric, currentPrice = it.prpr,
profitRate = it.evlu_pfls_rt, evalAmount = it.evlu_amt, isDomestic = true
))
).apply {
if (it.hldg_qty.toLong() > 0) {
println("보유 종목 : ${it.prdt_name} , 수량 : ${it.hldg_qty}")
}
})
}
// 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑)
@ -437,7 +442,7 @@ object KisTradeService {
if (response.status == HttpStatusCode.OK) {
val approvalKey = response.body<Map<String, String>>()["approval_key"]
if (approvalKey != null) {
KisSession.config = KisSession.config.copy(websocketToken = approvalKey)
KisSession.config = KisSession.config.copy(websocketToken = approvalKey,)
true
} else false
} else false
@ -500,35 +505,65 @@ object KisTradeService {
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
val config = KisSession.config
val baseUrl = prodUrl
val trId = "TTTC8434R"
var pureAccount = config.accountNo.replace("-", "").trim()
if (pureAccount.length == 8) pureAccount += "01"
val config = KisSession.config
val baseUrl = prodUrl
val trId = "TTTC8434R"
val cano = pureAccount.take(8)
val acntPrdtCd = pureAccount.takeLast(2)
return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer ${config.tradeToken}")
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
header("tr_id", trId)
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntPrdtCd)
parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N")
parameter("INQR_DVSN", "0")
parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
parameter("PRCS_DVSN", "00")
parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "")
val allHoldings = mutableListOf<StockHolding>()
var totalBalance: StockBalanceResponse? = null
// 연속 조회를 위한 변수
var ctxAreaFk = ""
var ctxAreaNk = ""
var trCont = "N" // 'N': 최초 조회, 'F': 다음 조회, 'M': 연속 조회
try {
do {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer ${config.tradeToken}")
header("appkey", if (config.isSimulation) config.vtsAppKey else config.realAppKey)
header("appsecret", if (config.isSimulation) config.vtsSecretKey else config.realSecretKey)
header("tr_id", trId)
header("tr_cont", trCont) // 연속 조회 키 설정
val pureAccount = config.realAccountNo.replace("-", "").trim()
parameter("CANO", pureAccount.take(8))
parameter("ACNT_PRDT_CD", pureAccount.takeLast(2))
parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N")
parameter("INQR_DVSN", "0")
parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
parameter("PRCS_DVSN", "00")
// 연속 조회 파라미터 전달
parameter("CTX_AREA_FK100", ctxAreaFk)
parameter("CTX_AREA_NK100", ctxAreaNk)
}
val body = response.body<StockBalanceResponse>()
// 데이터 합치기
allHoldings.addAll(body.output1)
if (totalBalance == null) totalBalance = body
// 헤더에서 다음 조회를 위한 키값 추출
trCont = response.headers["tr_cont"] ?: "D" // 'D' 또는 'E'는 끝을 의미
ctxAreaFk = response.headers["ctx_area_fk100"] ?: ""
ctxAreaNk = response.headers["ctx_area_nk100"] ?: ""
delay(250)
} while (trCont == "F" || trCont == "M") // 연속 데이터가 있는 동안 반복
// 모든 데이터를 합친 최종 객체 반환
return if (totalBalance != null) {
Result.success(totalBalance.copy(output1 = allHoldings))
} else {
println(totalBalance.toString())
Result.failure(Exception("No data found"))
}
} catch (e: Exception) {
e.printStackTrace()
return Result.failure(e)
}
val body = response.body<StockBalanceResponse>()
Result.success(body)
} catch (e: Exception) { Result.failure(e) }
}
private suspend fun fetchOverseasRawBalance(): Result<StockBalanceResponse> {

View File

@ -137,6 +137,7 @@ object RagService {
try {
var tradingDecision: TradingDecision = TradingDecision()
tradingDecision.stockCode = stockCode
tradingDecision.analyzer = technicalAnalyzer
tradingDecision.currentPrice = currentPrice
var corpInfo = DartCodeManager.getCorpCode(stockCode)
corpInfo?.stockName = stockName
@ -358,7 +359,7 @@ class TradingDecision {
var techSummary : String? = null
var newsContext : String? = null
var financialData : String? = null
var analyzer : TechnicalAnalyzer? = null
fun shortPossible() =
listOf<Double>(ultraShortScore,

View File

@ -13,16 +13,17 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import model.CandleData
import model.MAX_BUDGET
import model.MAX_PRICE
import model.MIN_PRICE
import model.ConfigIndex
import model.KisSession
import model.RankingStock
import model.RankingType
import network.DartCodeManager
import network.FinancialMapper
import network.FinancialStatement
import network.KisTradeService
import util.MarketUtil
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
@ -76,6 +77,40 @@ object AutoTradingManager {
runDiscoveryLoop(tradeService, callback)
}
suspend fun resumePendingSellOrders(tradeService: KisTradeService) {
// 1. DB에서 매도 중(SELLING)이거나 만료(EXPIRED)된 매도 건을 가져옵니다.
val pendingSells = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.SELLING, TradeStatus.EXPIRED))
pendingSells.forEach { item ->
// 2. 실제로 잔고에 해당 종목이 있는지 확인 (안전장치)
val balance = tradeService.fetchIntegratedBalance().getOrNull()
val holding = balance?.holdings?.find { it.code == item.code }
if (holding != null && holding.quantity.toInt() > 0) {
var final = MarketUtil.roundToTickSize(item.targetPrice)
println("🔄 [재주문] ${item.name} (${item.code}) ${item.orderedPrice} ${final} 전날 미체결 매도 건 재주문 시도")
// 3. 기존 목표가(targetPrice)로 다시 매도 주문 전송
tradeService.postOrder(
stockCode = item.code,
qty = item.quantity.toString(),
price = final.toLong().toString(),
isBuy = false
).onSuccess { newOrderNo ->
// 4. 새로운 주문번호로 DB 업데이트 및 상태를 SELLING으로 유지
DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.SELLING, newOrderNo)
println("✅ [재주문 완료] ${item.name}: $newOrderNo")
}.onFailure {
println("❌ [재주문 실패] ${item.name}: ${it.message}")
}
} else {
// 잔고에 없다면 이미 매도된 것으로 간주하고 완료 처리
DatabaseFactory.updateStatusAndOrderNo(item.id!!, TradeStatus.COMPLETED)
}
delay(200) // API 호출 부하 방지
}
}
private fun runDiscoveryLoop(tradeService: KisTradeService, callback: TradingDecisionCallback) {
discoveryJob = scope.launch {
println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}")
@ -91,7 +126,7 @@ object AutoTradingManager {
val now = LocalTime.now(ZoneId.of("Asia/Seoul"))
//&& now.isBefore(LocalTime.of(15, 30))
if (now.isAfter(LocalTime.of(15, 30)) ) {
executeClosingLiquidation(tradeService)
// executeClosingLiquidation(tradeService)
return@withTimeout
}
@ -136,7 +171,7 @@ object AutoTradingManager {
iterator.remove()
}
println("남은 후보군 개수 : ${totalCount}")
delay(150)
delay(250)
}
println("⏱️ [Cycle End] ${LocalTime.now()}")
@ -145,10 +180,10 @@ object AutoTradingManager {
println("⏳ [Cycle Timeout] 사이클이 너무 길어져 초기화 후 재시작합니다.")
} catch (e: Exception) {
println("⚠️ [Loop Error] ${e.message}")
delay(5000)
delay(3000)
}
waitForNextCycle(0.5)
waitForNextCycle(0.3)
}
}
}
@ -164,6 +199,9 @@ object AutoTradingManager {
private suspend fun processSingleStock(stock: RankingStock, myCash: Long, tradeService: KisTradeService, callback: TradingDecisionCallback) {
try {
val maxBudget = KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX)
val maxPrice = KisSession.config.getValues(ConfigIndex.MAX_PRICE_INDEX)
val minPrice = KisSession.config.getValues(ConfigIndex.MIN_PRICE_INDEX)
// 개별 종목 분석은 최대 2분으로 제한
withTimeout(120000L) {
val corpInfo = DartCodeManager.getCorpCode(stock.code)
@ -185,8 +223,8 @@ object AutoTradingManager {
}
val currentPrice = today.stck_prpr.toDouble()
if (currentPrice > myCash || currentPrice > MAX_BUDGET || currentPrice > MAX_PRICE || currentPrice < MIN_PRICE) {
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${MIN_PRICE}, 최대 기준:${MAX_PRICE}] | ")
if (currentPrice > myCash || currentPrice > maxBudget || currentPrice > maxPrice || currentPrice < minPrice) {
print("-> 가격 정책으로 제외 [1주:${currentPrice}, 자산:${myCash}, 최소 기준:${minPrice}, 최대 기준:${maxPrice}] | ")
return@withTimeout
}
@ -251,7 +289,7 @@ object AutoTradingManager {
while (System.currentTimeMillis() < endWait && isRunning()) {
lastTickTime.set(System.currentTimeMillis()) // 대기 중에도 Watchdog에 생존 신고
println("💤 대기 모드 상태 확인...")
delay(5000)
delay(1000)
}
}
@ -382,13 +420,41 @@ data class InvestmentScores(
""".trimIndent()
}
}
@Serializable
class TechnicalAnalyzer {
var monthly: List<CandleData> = emptyList()
var weekly: List<CandleData> = emptyList()
var daily: List<CandleData> = emptyList()
var min30: List<CandleData> = emptyList()
fun isOverheatedStock(): Boolean {
if (min30.size < 20 || daily.size < 20) return false
val currentPrice = min30.last().stck_prpr.toDouble()
// 1. 일봉 기준 이격도 체크 (20일 이평선 대비)
val ma20Daily = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
val disparityDaily = (currentPrice / ma20Daily) * 100
// 20일 평균선보다 25% 이상 떠 있다면 매우 위험 (과열)
if (disparityDaily > 125.0) return true
// 2. 분봉(30분봉) 기준 단기 급등 체크
val startPrice30 = min30.first().stck_oprc.toDouble()
val riseRate30 = ((currentPrice - startPrice30) / startPrice30) * 100
// 최근 30분봉 데이터(약 수 시간) 내에서 15% 이상 급등했다면 추격 매수 위험
if (riseRate30 > 15.0) return true
// 3. 비정상적 거래량 폭발 (매집봉 없는 단기 펌핑)
val avgVol = min30.dropLast(3).map { it.cntg_vol.toDouble() }.average()
val recentVol = min30.last().cntg_vol.toDouble()
// 평균 거래량보다 10배 이상 갑자기 터진 거래량은 세력의 털기(Exhaustion)일 수 있음
if (recentVol > avgVol * 10) return true
// 4. 볼린저 밴드 상단 이탈 강도
// ScalpingAnalyzer의 bollingerBands를 활용해 bbUpper보다 크게 이탈했는지 확인
return false
}
fun calculateScores(
financialScore: Int // 재무제표 점수 (성장률 등 기반)

View File

@ -51,8 +51,8 @@ fun AiAnalysisView(technicalAnalyzer: TechnicalAnalyzer,stockCode:String,stockNa
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.fillMaxHeight(0.15F)
.verticalScroll(rememberScrollState()) // 스크롤 활성화
.padding(16.dp)
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))) {

View File

@ -5,22 +5,28 @@ import AutoTradeItem
import TradingDecision
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.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.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import model.CandleData
import model.ConfigIndex
import model.ExecutionData
import model.KisSession
import model.StockBasicInfo
import model.feesAndTaxRate
import model.minimumNetProfit
import network.KisTradeService
import network.KisWebSocketManager
import service.AutoTradingManager
@ -54,7 +60,7 @@ fun DashboardScreen() {
CoroutineScope(Dispatchers.Default).launch {
// while (true) {
// delay(60000) // 1분마다 체크
AutoTradingManager.checkAndRestart(tradeService, callback)
AutoTradingManager.checkAndRestart(tradeService, callback)
// }
}
}
@ -121,7 +127,7 @@ fun DashboardScreen() {
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
val minEffectiveRate = minimumNetProfit + feesAndTaxRate
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
@ -175,7 +181,7 @@ fun DashboardScreen() {
val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
val monitoringCodes = monitoringTrades.map { it.code }.toSet()
wsManager.updateSubscriptions(monitoringCodes)
AutoTradingManager.resumePendingSellOrders(tradeService)
refreshTrigger++
}
@ -191,7 +197,7 @@ fun DashboardScreen() {
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
BalanceSection(tradeService,
onRefresh = { refreshTrigger++ },
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
@ -235,7 +241,8 @@ fun DashboardScreen() {
}
VerticalDivider()
Column(modifier = Modifier.weight(0.2f).fillMaxHeight().padding(8.dp)) {
Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) {
AiAnalysisView(
technicalAnalyzer = TechnicalAnalyzer().apply {
this.min30 = min30
@ -254,9 +261,177 @@ fun DashboardScreen() {
}
}
)
Spacer(modifier = Modifier.height(16.dp))
Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2), // 2열 병렬 배치
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().weight(0.3f)
) {
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰 거래 기본 설정",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.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.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.h6,
modifier = Modifier.padding(top = 16.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.h6,
modifier = Modifier.padding(top = 16.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
// if (configKey.name.contains("PROFIT")) {
// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
// }
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(configKey) + 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(configKey) + 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
)
}
}
}
}
VerticalDivider()
Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
AutoTradeSection(
isDomestic = isDomestic,
tradeService = tradeService,
@ -277,7 +452,7 @@ fun DashboardScreen() {
}
VerticalDivider()
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
Column(modifier = Modifier.weight(0.125f).fillMaxHeight().padding(8.dp)) {
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
MarketSection(tradeService) { code, name, isDom ->
val info = StockBasicInfo(
code = code,
@ -301,4 +476,34 @@ fun DashboardScreen() {
@Composable
fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}
fun findLongestCommonSubstring(s1: String, s2: String): String {
if (s1.isEmpty() || s2.isEmpty()) return ""
var longest = ""
// 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임
val reference = if (s1.length <= s2.length) s1 else s2
val target = if (s1.length <= s2.length) s2 else s1
for (i in reference.indices) {
for (j in (i + longest.length + 1)..reference.length) {
val sub = reference.substring(i, j)
if (target.contains(sub)) {
if (sub.length > longest.length) {
longest = sub
}
} else {
// target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출
break
}
}
}
return longest
}
fun getRemaining(original: String, common: String): String {
if (common.isEmpty()) return original
// 가장 처음 발견되는 공통 문자열을 한 번만 제거
return original.replaceFirst(common, "").trim()
}

View File

@ -18,17 +18,16 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.MAX_BUDGET
import model.MIN_PURCHASE_SCORE
import model.ConfigIndex
import model.KisSession
import model.RankingStock
import model.buyWeight
import model.feesAndTaxRate
import model.minimumNetProfit
import network.KisTradeService
import service.AutoTradingManager
import util.MarketUtil
import kotlin.math.min
enum class InvestmentGrade(
val displayName: String,
@ -36,7 +35,8 @@ enum class InvestmentGrade(
val shortWeight: Double = 0.0,
val midWeight: Double = 0.0,
val longWeight: Double = 0.0,
val profitGuide: Double = 0.0,
val profitGuide: ConfigIndex,
val buyGuide: ConfigIndex,
) {
LEVEL_5_STRONG_RECOMMEND(
displayName = "최상급 추천",
@ -44,7 +44,8 @@ enum class InvestmentGrade(
shortWeight = 1.0,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = 1.8,
profitGuide = ConfigIndex.GRADE_5_PROFIT,
buyGuide = ConfigIndex.GRADE_5_BUY,
),
LEVEL_4_BALANCED_RECOMMEND(
displayName = "균형 추천",
@ -52,7 +53,8 @@ enum class InvestmentGrade(
shortWeight = 0.8,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = 1.3,
profitGuide = ConfigIndex.GRADE_4_PROFIT,
buyGuide = ConfigIndex.GRADE_4_BUY,
),
LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천",
@ -60,7 +62,8 @@ enum class InvestmentGrade(
shortWeight = 0.6,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = 0.9,
profitGuide = ConfigIndex.GRADE_3_PROFIT,
buyGuide = ConfigIndex.GRADE_3_BUY,
),
LEVEL_2_HIGH_RISK(
displayName = "고위험 추천",
@ -68,7 +71,8 @@ enum class InvestmentGrade(
shortWeight = 1.0,
midWeight = 0.4,
longWeight = 0.4,
profitGuide = 0.7,
profitGuide = ConfigIndex.GRADE_2_PROFIT,
buyGuide = ConfigIndex.GRADE_2_BUY,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
@ -76,7 +80,8 @@ enum class InvestmentGrade(
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2,
profitGuide = 0.5,
profitGuide = ConfigIndex.GRADE_1_PROFIT,
buyGuide = ConfigIndex.GRADE_1_BUY,
)
}
@ -128,7 +133,7 @@ fun IntegratedOrderSection(
}
var profitRate by remember(monitoringItem) {
mutableStateOf(monitoringItem?.profitRate?.toString() ?: minimumNetProfit.toString())
mutableStateOf(monitoringItem?.profitRate?.toString() ?: KisSession.config.getValues(ConfigIndex.PROFIT_INDEX).toString())
}
var stopLossRate by remember(monitoringItem) {
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-1.5")
@ -164,19 +169,19 @@ fun IntegratedOrderSection(
return when {
// LEVEL_5: 단기·중기·장기 모두 매우 높고, 신뢰도까지 높음
shortAvg >= 85.0 && midLongAvg >= 80.0 ->
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND else InvestmentGrade.LEVEL_5_STRONG_RECOMMEND
// LEVEL_4: 중기·장기 기본 준수, 단기까지 양호
midLongAvg >= 75.0 && shortAvg >= 70.0 ->
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND
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 ->
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_2_HIGH_RISK else InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
// LEVEL_2: 단기/초단기만 강하고, 중기·장기 애매
shortAvg >= 75.0 && midLongAvg < 65.0 ->
InvestmentGrade.LEVEL_2_HIGH_RISK
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
// LEVEL_1: 단기/초단기만 의미 있고, 중기·장기 심각히 약함
shortAvg >= 70.0 && midLongAvg < 55.0 ->
@ -184,30 +189,24 @@ fun IntegratedOrderSection(
// 기본 조건은 충족했지만, 패턴에 잘 맞지 않을 때 (예: 중립)
else ->
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND
if (ts.analyzer?.isOverheatedStock() ?: true) InvestmentGrade.LEVEL_1_SPECULATIVE else InvestmentGrade.LEVEL_2_HIGH_RISK
}
}
fun excuteTrade(willEnableAutoSell: Boolean, orderQty: String, profitRate1: Double?,investmentGrade: InvestmentGrade = InvestmentGrade.LEVEL_2_HIGH_RISK) {
scope.launch {
val tickSize = MarketUtil.getTickSize(basePrice)
val oneTickLowerPrice = basePrice - (tickSize * when(investmentGrade) {
InvestmentGrade.LEVEL_5_STRONG_RECOMMEND -> 0
InvestmentGrade.LEVEL_4_BALANCED_RECOMMEND -> 1
InvestmentGrade.LEVEL_3_CAUTIOUS_RECOMMEND -> 2
InvestmentGrade.LEVEL_2_HIGH_RISK -> 2
InvestmentGrade.LEVEL_1_SPECULATIVE -> 3
else -> 3
})
val oneTickLowerPrice = basePrice - (tickSize * KisSession.config.getValues(investmentGrade.buyGuide).toInt())
// 2. 주문 가격 설정 (직접 입력값이 없으면 한 틱 낮은 가격 사용)
val finalPrice = if (orderPrice.isBlank()) {
oneTickLowerPrice.toLong().toString()
val finalPrice = MarketUtil.roundToTickSize(if (orderPrice.isBlank()) {
oneTickLowerPrice.toDouble()
} else {
orderPrice
}
orderPrice.toDouble()
})
println("basePrice : $basePrice, oneTickLowerPrice : $oneTickLowerPrice, finalPrice : $finalPrice")
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
tradeService.postOrder(stockCode, orderQty, finalPrice.toLong().toString(), isBuy = true)
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
onOrderResult("주문 성공: $realOrderNo", true)
if (willEnableAutoSell) {
@ -220,7 +219,9 @@ fun IntegratedOrderSection(
// 3. 실질 목표 수익률 계산
// 사용자가 입력한 pRate와 (최소 순수익 + 제반 비용) 중 큰 값을 선택합니다.
val effectiveProfitRate = maxOf((profitRate1 ?: pRate) + feesAndTaxRate, minimumNetProfit + feesAndTaxRate)
var tax = KisSession.config.getValues(ConfigIndex.TAX_INDEX)
val effectiveProfitRate = maxOf(((profitRate1 ?: pRate) + tax), (KisSession.config.getValues(
ConfigIndex.PROFIT_INDEX) + tax))
// 4. 보정된 수익률을 적용하여 목표가 계산
val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + effectiveProfitRate / 100.0))
@ -241,7 +242,7 @@ fun IntegratedOrderSection(
))
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
onOrderSaved(realOrderNo)
onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.2f", effectiveProfitRate)}%): $realOrderNo", true)
onOrderResult("매수 및 감시 설정 완료 (목표 수익률: ${String.format("%.4f", effectiveProfitRate)}%): $realOrderNo", true)
}
}
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
@ -257,6 +258,11 @@ fun IntegratedOrderSection(
completeTradingDecision.stockCode.equals(stockCode)) {
basePrice = completeTradingDecision.currentPrice
println("basePrice $basePrice")
val minScore = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX)
val 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.3, // 초단기 점수가 낮아도 전체에 미치는 영향 감소
@ -269,10 +275,10 @@ fun IntegratedOrderSection(
((completeTradingDecision.profitPossible() + append) * weights["profit"]!!) +
((completeTradingDecision.safePossible() + append) * weights["safe"]!!)
if (totalScore >= MIN_PURCHASE_SCORE && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
if (totalScore >= minScore && completeTradingDecision.confidence >= MIN_CONFIDENCE) {
var investmentGrade : InvestmentGrade = getInvestmentGrade(completeTradingDecision,totalScore, completeTradingDecision.confidence)
val finalMargin = minimumNetProfit * investmentGrade.profitGuide
val finalMargin = baseProfit * KisSession.config.getValues(investmentGrade.profitGuide)
println("""
사명 : ${completeTradingDecision.corpName}
신뢰도 : ${completeTradingDecision.confidence + append}
@ -287,19 +293,19 @@ fun IntegratedOrderSection(
// basePrice(현재가 혹은 지정가)를 기준으로 매수 가능 수량 산출 (최소 1주 보장)
val calculatedQty = if (basePrice > 0) {
(MAX_BUDGET / basePrice).toInt().coerceAtLeast(1)
(maxBudget / basePrice).toInt().coerceAtLeast(1)
} else {
1
}
// 5. 매수 실행 (계산된 finalMargin 전달)
excuteTrade(
willEnableAutoSell = true,
orderQty = calculatedQty.toString(),
orderQty = min(calculatedQty, KisSession.config.getValues(ConfigIndex.MAX_COUNT_INDEX).toInt()).toString(),
profitRate1 = finalMargin,
investmentGrade = investmentGrade,
)
} else if(totalScore >= (MIN_PURCHASE_SCORE * 0.85) && completeTradingDecision.confidence + append >= (MIN_CONFIDENCE * 0.85)) {
} 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))
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
} else {

View File

@ -47,16 +47,16 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
item {
Text("거래 방식 선택", style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) })
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) })
Text("실전투자")
Spacer(Modifier.width(16.dp))
RadioButton(selected = config.isSimulation, onClick = { config = config.copy(isSimulation = true) })
RadioButton(selected = config.isSimulation, onClick = { config = config.copy() })
Text("모의투자")
}
Divider(Modifier.padding(vertical = 12.dp))
OutlinedTextField(
value = config.htsId,
onValueChange = { config = config.copy(htsId = it) },
onValueChange = { config = config.copy(htsId = it,) },
label = { Text("HTS ID (실시간 체결 통보용)") },
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") }
@ -64,22 +64,22 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
// 실전 3종 입력
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.realAccountNo, onValueChange = {
config = config.copy(realAccountNo = it)
config = config.copy(realAccountNo = it,)
if(it.length >= 8) checkAndLoadConfig(it, true)
}, 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.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it,) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(16.dp))
// 모의 3종 입력
Text("모의투자 정보", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
config = config.copy(vtsAccountNo = it)
config = config.copy(vtsAccountNo = it,)
if(it.length >= 8) checkAndLoadConfig(it, false)
}, 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.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it,) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth())
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))
@ -91,7 +91,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
val data = state.dragData
if (data is DragData.FilesList) {
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path)
if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path,)
}
}),
contentAlignment = Alignment.Center
@ -104,7 +104,7 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
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)
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,)
}
}),
contentAlignment = Alignment.Center