.....
This commit is contained in:
parent
df124612ab
commit
c4f58f159a
@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
@ -503,19 +508,27 @@ object KisTradeService {
|
||||
val config = KisSession.config
|
||||
val baseUrl = prodUrl
|
||||
val trId = "TTTC8434R"
|
||||
var pureAccount = config.accountNo.replace("-", "").trim()
|
||||
if (pureAccount.length == 8) pureAccount += "01"
|
||||
|
||||
val cano = pureAccount.take(8)
|
||||
val acntPrdtCd = pureAccount.takeLast(2)
|
||||
return try {
|
||||
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)
|
||||
parameter("CANO", cano)
|
||||
parameter("ACNT_PRDT_CD", acntPrdtCd)
|
||||
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")
|
||||
@ -523,12 +536,34 @@ object KisTradeService {
|
||||
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", "")
|
||||
// 연속 조회 파라미터 전달
|
||||
parameter("CTX_AREA_FK100", ctxAreaFk)
|
||||
parameter("CTX_AREA_NK100", ctxAreaNk)
|
||||
}
|
||||
|
||||
val body = response.body<StockBalanceResponse>()
|
||||
Result.success(body)
|
||||
} catch (e: Exception) { Result.failure(e) }
|
||||
|
||||
// 데이터 합치기
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchOverseasRawBalance(): Result<StockBalanceResponse> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 // 재무제표 점수 (성장률 등 기반)
|
||||
|
||||
@ -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))) {
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
@ -302,3 +477,33 @@ fun DashboardScreen() {
|
||||
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()
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user