diff --git a/src/main/kotlin/analyzer/FinancialAnalyzer.kt b/src/main/kotlin/analyzer/FinancialAnalyzer.kt index 05e3f68..31e1210 100644 --- a/src/main/kotlin/analyzer/FinancialAnalyzer.kt +++ b/src/main/kotlin/analyzer/FinancialAnalyzer.kt @@ -23,7 +23,7 @@ object FinancialAnalyzer { val isNotLossExploding = fs.lossToSalesRatio < 150.0 // 매출보다 손실이 너무 크면 차단 // 최종: 정말 위험한 경우가 아니면 분석 단계(AI/뉴스)로 보냄 - return isDebtSafe && isLiquiditySafe && isNotFatalDeficit && isNotCapitalImpaired + return isDebtSafe && isLiquiditySafe && isNotFatalDeficit && isNotCapitalImpaired && isNotLossExploding } fun toString(fs : FinancialStatement): String { diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 9b4de6e..97f83b5 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -470,7 +470,9 @@ object TradingLogStore { val stockName: String, val decision: String, val confidence: Double, - val reason: String + val reason: String, + var stockCode: String? = null, + var qty: Int? = 0, ) fun addLog(decision: TradingDecision) { @@ -519,15 +521,17 @@ object TradingLogStore { fun addAnalyzer(name : String, code : String, log: String, positive : Boolean = false) { synchronized(this) { if (decisionLogs.size > 1000) decisionLogs.removeAt(0) - decisionLogs.add( - LogEntry( - time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), - stockName = "$name[$code] 분석", - decision = if(positive) "ANALYZER" else "PASS", - confidence = 100.0, - reason = log + if (positive) { + decisionLogs.add( + LogEntry( + time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + stockName = "$name[$code] 분석", + decision = if (positive) "ANALYZER" else "PASS", + confidence = 100.0, + reason = log + ) ) - ) + } } } @@ -546,6 +550,24 @@ object TradingLogStore { } } + fun addNotice(name : String, code : String, log: String, qty: Int? = null) { + synchronized(this) { + if (decisionLogs.size > 1000) decisionLogs.removeAt(0) + decisionLogs.add( + LogEntry( + time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + stockName = "$name[$code] 분석", + decision = "NOTICE", + confidence = 100.0, + reason = log, + stockCode = code, + qty = qty + ) + ) + } + } + + fun addAfterMarketLog(name : String, code : String, log: String) { synchronized(this) { diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index ebfd649..5a72031 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -1,7 +1,11 @@ package model +import com.google.gson.Gson +import com.google.gson.GsonBuilder import report.TradingReportManager +import java.io.File import java.time.LocalDateTime +import java.time.LocalTime import kotlin.math.abs @@ -40,7 +44,8 @@ enum class ConfigIndex(val index : Int,val label : String) { LOSS_MINRATE(STOP_LOSS.index + 1, "손절 최소 기준") , LOSS_MAXRATE(LOSS_MINRATE.index + 1, "손절 최소 기준") , LOSS_MAX_MONEY(LOSS_MAXRATE.index + 1, "손절 최대 금액") , - MAX_HOLDING_COUNT(LOSS_MAX_MONEY.index + 1, "최대 보유 가능 종목 수") + MAX_HOLDING_COUNT(LOSS_MAX_MONEY.index + 1, "최대 보유 가능 종목 수"), + ; companion object { @@ -156,6 +161,8 @@ data class AppConfig( ConfigIndex.STOP_LOSS -> { stop_Loss = value > 0.1} ConfigIndex.TAKE_PROFIT -> { take_profit = value > 0.1 } ConfigIndex.MAX_HOLDING_COUNT -> { max_holding_count = value } + + } if (firstSet.contains(index)) { DatabaseFactory.saveConfig(KisSession.config) @@ -217,16 +224,31 @@ data class AppConfig( ConfigIndex.TAKE_PROFIT -> {if(!take_profit) 0.0 else 1.0} ConfigIndex.MAX_COUNT_INDEX -> {MAX_COUNT.toDouble()} ConfigIndex.MAX_HOLDING_COUNT -> { max_holding_count} + } } } +class TradeConfig { + var auto_cancel_pending_rate : Double = 3.0 + var auto_cancel_pending_time : Long = 60L * 60L * 1000L + var auto_cancel_pending_buy : Boolean = false + var auto_start_time : String = "08:00" + var auto_end_time : String = "20:00" + var before_nxt : Boolean = false + var after_nxt : Boolean = false + var start_buy_time : String = "08:55" + var end_buy_time : String = "15:10" + var enableOverSea : Boolean = false +} // [신규] 전역에서 참조할 단일 세션 객체 object KisSession { + var config: AppConfig = AppConfig() + var tradeConfig : TradeConfig = TradeConfig() fun getWebSocketKey() = config.websocketToken // 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주) fun isMarketTokenValid(): Boolean { @@ -239,4 +261,75 @@ object KisSession { return config.tradeToken.isNotEmpty() && config.tradeTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false } + + private val configFile = File("trade_config.json") + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + + /** + * JSON 파일로부터 설정을 불러옵니다. + * 파일이 없으면 기본 설정 객체를 생성하고 저장한 뒤 반환합니다. + */ + fun loadTradeConfig(): TradeConfig { + return if (configFile.exists()) { + try { + val jsonString = configFile.readText() + gson.fromJson(jsonString, TradeConfig::class.java) + } catch (e: Exception) { + println("설정 로드 실패: ${e.message}") + TradeConfig() // 오류 발생 시 기본값 반환 + } + } else { + // 파일이 없으면 새로 만들어서 저장 + val newConfig = TradeConfig() + saveTradeConfig() + newConfig + } + } + + /** + * TradeConfig 객체를 JSON 파일로 저장합니다. + */ + fun saveTradeConfig() { + try { + val jsonString = gson.toJson(tradeConfig) + configFile.writeText(jsonString) + println("설정이 성공적으로 저장되었습니다. $jsonString") + } catch (e: Exception) { + println("설정 저장 실패: ${e.message}") + } + } + + fun startTime() : LocalTime { + return getStartTimeAsLocalTime(tradeConfig.auto_start_time) + } + fun endTime() : LocalTime { + return getStartTimeAsLocalTime(tradeConfig.auto_end_time) + } + fun startBuyTime() : LocalTime { + return getStartTimeAsLocalTime(tradeConfig.start_buy_time) + } + fun endBuyTime() : LocalTime { + return getStartTimeAsLocalTime(tradeConfig.end_buy_time) + } + + fun getStartTimeAsLocalTime(config: String): java.time.LocalTime { + return try { + java.time.LocalTime.parse(config) + } catch (e: Exception) { + java.time.LocalTime.of(9, 0) // 파싱 실패 시 기본값 + } + } + + // 2. 오늘 해당 시간까지의 밀리초(Long) 계산 + fun getTimeMillis(timeStr: String): Long { + val parts = timeStr.split(":") + if (parts.size != 2) return 0L + val hour = parts[0].toLongOrNull() ?: 0L + val min = parts[1].toLongOrNull() ?: 0L + return (hour * 3600 + min * 60) * 1000L + } + + fun isAvailBuyTime(now: LocalTime) : Boolean { + return now.isBefore(endBuyTime()) && now.isAfter(startBuyTime()) + } } \ No newline at end of file diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index cc4e96c..7406912 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -192,7 +192,8 @@ data class UnfilledOrder( @SerialName("psbl_qty") val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑 val ord_dvsn_name: String, - val rvse_cncl_dvsn_name: String + val rvse_cncl_dvsn_name: String, + val ord_tmd : String, ) { fun isBuyOrder() : Boolean { return true @@ -234,3 +235,119 @@ data class ExecutionData( val qty: String, val isFilled: Boolean ) + + +data class CurrentPriceResponse( + val rt_cd: String, // 0: 성공, 0 이외: 실패 + val msg_cd: String, + val msg1: String, + val output: CurrentPriceOutput +) + +data class CurrentPriceOutput( + val stck_prpr: String, // 주식 현재가 + val prdy_vrss: String, // 전일 대비 + val prdy_ctrt: String, // 전일 대비율 + val acml_vol: String, // 누적 거래량 + val stck_oprc: String, // 시가 + val stck_hgpr: String, // 고가 + val stck_lwpr: String, // 저가 + val hts_avls: String, // 시가총액 + val per: String, + val pbr: String, + val stck_shrn_iscd: String // 종목 코드 + // ... 필요한 필드가 있다면 Python 모델을 참고하여 추가하세요. +) + + +@Serializable +data class OverseasBalanceResponse( + val rt_cd: String, + val msg_cd: String, + val msg1: String, + val ctx_area_fk200: String? = null, + val ctx_area_nk200: String? = null, + val output1: List = emptyList(), + val output2: OverseasBalanceSummary? = null +) + +@Serializable +data class OverseasStockHolding( + val ovrs_pdno: String, // 종목코드 + val ovrs_item_name: String, // 종목명 + val frcr_evlu_pfls_amt: String, // 외화평가손익금액 + val evlu_pfls_rt: String, // 평가손익율 + val pchs_avg_pric: String, // 매입평균가격 + val ovrs_cblc_qty: String, // 잔고수량 + val ord_psbl_qty: String, // 주문가능수량 + val ovrs_stck_evlu_amt: String, // 평가금액 + val now_pric2: String, // 현재가 + val tr_crcy_cd: String, // 통화코드 + val ovrs_excg_cd: String // 거래소코드 +) + +@Serializable +data class OverseasBalanceSummary( + val frcr_pchs_amt1: String, // 외화매입금액합계 + val ovrs_tot_pfls: String, // 해외총손익 + val tot_pftrt: String, // 총수익률 + val tot_evlu_pfls_amt: String // 총평가손익금액 +) + + +@Serializable +data class OverseasOrderResponse( + val rt_cd: String, // 성공 실패 여부 (0: 성공, 이외 실패) + val msg_cd: String, // 응답코드 + val msg1: String, // 응답메세지 + val output: OverseasOrderOutput? = null +) + +@Serializable +data class OverseasOrderOutput( + val KRX_FWDG_ORD_ORGNO: String = "", // 한국거래소전송주문조직번호 + val ODNO: String = "", // 주문번호 + val ORD_TMD: String = "" // 주문시각 +) + +@Serializable +data class OverseasAnalysisResponse( + val rt_cd: String, val msg1: String, + val output: List = emptyList() +) + +@Serializable +data class OverseasAnalysisItem( + val excd: String, // 거래소 (NAS, NYS) + val syml: String, // 종목코드 (AAPL) + val name: String, // 종목명 + val last: String, // 현재가 + val rate: String, // 등락률 + val tvol: String // 거래량 +) + +// [시세] 기간별 시세 응답 +@Serializable +data class OverseasDailyPriceResponse( + val rt_cd: String, val msg1: String, + val output1: OverseasPriceOutput1, + val output2: List = emptyList() +) + +@Serializable +data class OverseasPriceOutput1(val rsym: String, val nrec: String) + +@Serializable +data class OverseasPriceOutput2( + val xymd: String, val clos: String, val open: String, + val high: String, val low: String, val tvol: String +) + +// [재무] FMP API용 핵심 지표 +@Serializable +data class KeyMetrics( + val symbol: String, + val currentRatio: Double, // 유동비율 (> 1.0 권장) + val debtToEquity: Double, // 부채비율 (< 1.5 권장) + val roe: Double // 자기자본이익률 +) \ No newline at end of file diff --git a/src/main/kotlin/network/KisOverseasService.kt b/src/main/kotlin/network/KisOverseasService.kt new file mode 100644 index 0000000..87997fd --- /dev/null +++ b/src/main/kotlin/network/KisOverseasService.kt @@ -0,0 +1,212 @@ +package network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import jdk.javadoc.internal.doclets.formats.html.markup.HtmlStyle +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import model.KeyMetrics + +enum class OverseasRankType(val url: String, val trId: String) { + PRICE_FLUCT("/uapi/overseas-stock/v1/ranking/price-fluct", "HHDFS76260000"), // 가격급등락 + VOLUME_SURGE("/uapi/overseas-stock/v1/ranking/volume-surge", "HHDFS76270000"), // 거래량급증 + VOLUME_POWER("/uapi/overseas-stock/v1/ranking/volume-power", "HHDFS76280000"), // 매수체결강도상위 + UPDOWN_RATE("/uapi/overseas-stock/v1/ranking/updown-rate", "HHDFS76290000") // 상승률/하락률 +} + +// --- [공통 응답 구조] --- +@Serializable +data class OverseasResponse( + val rt_cd: String, // 0: 성공, 이외 실패 + val msg_cd: String, + val msg1: String, + val output: T? = null, + val output1: OverseasPriceOutput1? = null, // 시세 조회용 + val output2: List = emptyList() // 시세 리스트용 +) + +// --- [랭킹/분석용 상세 아이템] --- +@Serializable +data class OverseasAnalysisItem( + val excd: String = "", // 거래소 코드 + val syml: String = "", // 종목 심볼 + val name: String = "", // 종목명 + val last: String = "", // 현재가 + val rate: String = "", // 등락률 + val tvol: String = "", // 거래량 + val tamt: String = "" // 거래대금 +) + +// --- [시세 조회용 상세] --- +@Serializable +data class OverseasPriceOutput1(val rsym: String, val nrec: String) + +@Serializable +data class OverseasPriceOutput2( + val xymd: String, // 일자 + val clos: String, // 종가 + val open: String, // 시가 + val high: String, // 고가 + val low: String, // 저가 + val tvol: String, // 거래량 + val rate: String // 등락률 +) + +// --- [주문 응답] --- +@Serializable +data class OverseasOrderOutput( + val ODNO: String, // 주문번호 + val ORD_TMD: String // 주문시각 +) + +class KisOverseasService( + private val client: HttpClient, + private val appKey: String, + private val appSecret: String, + private var accessToken: String, + private val accountNo: String, + private val accountProdCode: String +) { + private val baseUrl = "https://openapi.koreainvestment.com:9443" + + /** 1. 해외 랭킹 조회 (가격급등락, 거래량급증 등) */ + suspend fun fetchRank(type: OverseasRankType, exchange: String): Result> { + return try { + val response = client.get("$baseUrl${type.url}") { + header("authorization", "Bearer $accessToken") + header("appkey", appKey) + header("appsecret", appSecret) + header("tr_id", type.trId) + header("custtype", "P") + + parameter("AUTH", "") + parameter("EXCD", exchange) + if (type == OverseasRankType.PRICE_FLUCT) parameter("GUBN", "0") // 0:급등 + } + val body = response.body>>() + if (body.rt_cd == "0") Result.success(body.output ?: emptyList()) + else Result.failure(Exception(body.msg1)) + } catch (e: Exception) { Result.failure(e) } + } + + /** 2. 해외 주식 주문 (매수/매도) */ + suspend fun sendOrder( + isBuy: Boolean, + exchange: String, + code: String, + qty: String, + price: String + ): Result { + return try { + val trId = if (isBuy) "TTTT1002U" else "TTTT1006U" + val response = client.post("$baseUrl/uapi/overseas-stock/v1/trading/order") { + header("authorization", "Bearer $accessToken") + header("appkey", appKey) + header("appsecret", appSecret) + header("tr_id", trId) + header("Content-Type", "application/json") + + setBody(mapOf( + "CANO" to accountNo, + "ACNT_PRDT_CD" to accountProdCode, + "OVRS_EXCG_CD" to exchange, + "PDNO" to code, + "ORD_QTY" to qty, + "OVRS_ORD_UNPR" to price, + "ORD_SVR_DVSN_CD" to "00", + "ORD_DVSN" to "00" + )) + } + val body = response.body>() + if (body.rt_cd == "0") Result.success(body.output!!) + else Result.failure(Exception(body.msg1)) + } catch (e: Exception) { Result.failure(e) } + } + + /** 3. 전략적 시세 조회 (일/주/월 선택) */ + suspend fun fetchChart( + exchange: String, + code: String, + gubn: String // 0:일, 1:주, 2:월 + ): List { + return try { + val response = client.get("$baseUrl/uapi/overseas-price/v1/quotations/dailyprice") { + header("authorization", "Bearer $accessToken") + header("appkey", appKey) + header("appsecret", appSecret) + header("tr_id", "HHDFS76240000") + + parameter("AUTH", "") + parameter("EXCD", exchange) + parameter("SYMB", code) + parameter("GUBN", gubn) + parameter("BYMD", "") + parameter("MODP", "1") + } + val body = response.body>() + if (body.rt_cd == "0") body.output2 else emptyList() + } catch (e: Exception) { emptyList() } + } +} + +class OverseasAnalyzer( + private val service: KisOverseasService, + private val financialService: OverseasFinancialService +) { + suspend fun executeAutoTrade() { + // 1. 나스닥/뉴욕 거래량 급증 종목 스캔 + val candidates = mutableListOf() + listOf("NAS", "NYS").forEach { ex -> + service.fetchRank(OverseasRankType.VOLUME_SURGE, ex).onSuccess { candidates.addAll(it) } + } + + for (stock in candidates.distinctBy { it.syml }.take(10)) { + // 2. 재무 필터링 (FMP API) - 부채비율 1.5 이하만 + val metrics = financialService.fetchKeyMetrics(stock.syml).getOrNull() ?: continue + if (metrics.debtToEquity > 1.5) continue + + // 3. 다중 차트 분석 (월봉으로 장기 추세 확인) + val monthlyChart = service.fetchChart(stock.excd, stock.syml, "2") + if (monthlyChart.size < 6) continue + + val isUpTrend = monthlyChart.first().clos.toDouble() > monthlyChart[5].clos.toDouble() + + // 4. 최종 매수 결정 + if (isUpTrend) { +// TradingLogStore.addLog("[해외매수결정] ${stock.name}(${stock.syml}) - 재무/추세 통과") + service.sendOrder(true, stock.excd, stock.syml, "1", "0") // 시장가 전량 1주 예시 + } + } + } +} + +class OverseasFinancialService( + private val client: HttpClient, + private val apiKey: String // FMP에서 발급받은 키 +) { + private val baseUrl = "https://financialmodelingprep.com/api/v3" + + /** + * 특정 종목의 핵심 재무 지표를 가져옵니다. + */ + suspend fun fetchKeyMetrics(symbol: String): Result { + return try { + val response: List = client.get("$baseUrl/key-metrics-ttm/$symbol") { + parameter("apikey", apiKey) + }.body() + + if (response.isNotEmpty()) { + Result.success(response[0]) + } else { + Result.failure(Exception("데이터가 없습니다.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index deafb9b..0024a98 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -82,11 +82,10 @@ object KisTradeService { val config = KisSession.config // 국내와 해외 잔고를 비동기로 동시 호출 val domesticJob = async { fetchDomesticRawBalance(marketCode) } - val overseasJob = async { fetchOverseasRawBalance() } + try { val domRes = domesticJob.await().getOrNull() - val ovsRes = overseasJob.await().getOrNull() val combinedHoldings = mutableListOf() @@ -108,43 +107,26 @@ object KisTradeService { }) } - // 2. 해외 종목 매핑 (신규 추가된 모델 파라미터 반영) - // 주의: 해외 API 응답(ovsRes)에 fltt_rt, pchs_amt와 정확히 일치하는 필드가 없다면 - // 해당하는 필드로 매핑하거나, 없을 경우 기본값("0.0", "0")을 넣어주어야 에러가 안 납니다. - ovsRes?.output1?.forEach { - combinedHoldings.add(UnifiedStockHolding( - 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 = false, - availOrderCount = it.ord_psbl_qty, thdtBuyQty = it.thdt_buyqty, - valuationProfitAmount = it.evlu_pfls_amt, - // 💡 [추가] 해외 필드에도 매핑 (해외 API 명세에 맞춰 필드명 조정 필요할 수 있음) - dailyChangeRate = it.fltt_rt ?: "0.0", - pchsAmount = it.pchs_amt ?: "0" - )) - } // 4. 최종 통합 잔고 반환 val domSummary = domRes?.output2?.firstOrNull() - val ovsSummary = ovsRes?.output2?.firstOrNull() // 1. 자산 및 금액 합산 - val totalPchsAmt = (domSummary?.pchs_amt_smtl_amt?.toLongOrNull() ?: 0L) + - (ovsSummary?.pchs_amt_smtl_amt?.toLongOrNull() ?: 0L) - val totalPflsAmt = (domSummary?.evlu_pfls_smtl_amt?.toLongOrNull() ?: 0L) + - (ovsSummary?.evlu_pfls_smtl_amt?.toLongOrNull() ?: 0L) + val totalPchsAmt = (domSummary?.pchs_amt_smtl_amt?.toLongOrNull() ?: 0L) + + val totalPflsAmt = (domSummary?.evlu_pfls_smtl_amt?.toLongOrNull() ?: 0L) + -// 2. 계좌 전체 수익률 계산: (평가손익합계 / 매입금액합계) * 100 val calculatedTotalRate = if (totalPchsAmt > 0) { (totalPflsAmt.toDouble() / totalPchsAmt.toDouble()) * 100 } else 0.0 // 3. 모델 생성 Result.success(UnifiedBalance( - totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)), + totalAsset = String.format("%,d", (domSummary?.tot_evlu_amt?.toLongOrNull() ?: 0L)), deposit = String.format("%,d", domSummary?.dnca_tot_amt?.toLongOrNull() ?: 0L), - dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)), - todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L) + (ovsSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)), + dailyAssetChange = String.format("%,d", (domSummary?.asst_icdc_amt?.toLongOrNull() ?: 0L)), + todayFees = String.format("%,d", (domSummary?.thdt_tlex_amt?.toLongOrNull() ?: 0L)), totalProfitRate = String.format("%.2f%%", calculatedTotalRate), // 계산된 값 전달 holdings = combinedHoldings )) @@ -494,6 +476,37 @@ object KisTradeService { } catch (e: Exception) { Result.failure(e) } } + suspend fun fetchCurrentPrice(stockCode: String): Result { + return try { + val config = KisSession.config + val response = client.get("https://openapi.koreainvestment.com:9443/uapi/domestic-stock/v1/quotations/inquire-price") { + // 헤더 설정 (토큰 및 기본 정보) + header("authorization", "Bearer ${config.tradeToken}") + header("appkey", config.realAppKey) + header("appsecret", config.realSecretKey) + header("tr_id", "FHKST01010100") // 주식 현재가 시세 TR ID + header("Content-Type", "application/json; charset=utf-8") + + // 파라미터 설정 + parameter("FID_COND_MRKT_DIV_CODE", "J") // J: 주식 + parameter("FID_INPUT_ISCD", stockCode) // 종목코드 + } + + if (response.status.isSuccess()) { + val body = response.body() + if (body.rt_cd == "0") { + Result.success(body.output) + } else { + Result.failure(Exception("API 에러: ${body.msg1}")) + } + } else { + Result.failure(Exception("HTTP 에러: ${response.status}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + /** * [4] 웹소켓 승인키(Approval Key) 발급 */ @@ -658,8 +671,99 @@ object KisTradeService { } } - private suspend fun fetchOverseasRawBalance(): Result { - // 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름) - return Result.failure(Exception("Not Implemented")) + suspend fun fetchOverseasBalance( + exchangeCode: String = "NASD", // NASD: 나스닥, NYSE: 뉴욕, AMEX: 아멕스 등 + currencyCode: String = "USD" + ): Result { + return try { + val config = KisSession.config + var pureAccount = config.accountNo.replace("-", "").trim() + if (pureAccount.length == 8) pureAccount += "01" + val cano = pureAccount.take(8) + val acntPrdtCd = pureAccount.takeLast(2) + + val response = client.get("https://openapi.koreainvestment.com:9443/uapi/overseas-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", "TTTS3012R") // 해외주식 잔고조회 실전 ID (모의는 다를 수 있음) + + // 쿼리 파라미터 (Python의 RequestQueryParam 대응) + parameter("CANO", cano) + parameter("ACNT_PRDT_CD", acntPrdtCd) + parameter("OVRS_EXCG_CD", exchangeCode) + parameter("TR_CRCY_CD", currencyCode) + parameter("CTX_AREA_FK200", "") + parameter("CTX_AREA_NK200", "") + } + + if (response.status.isSuccess()) { + val body = response.body() + Result.success(body) + } else { + Result.failure(Exception("HTTP Error: ${response.status}")) + } + } catch (e: Exception) { + Result.failure(e) + } } + + /** + * 해외 주식 주문 함수 + * @param isBuy true면 매수, false면 매도 + */ + suspend fun sendOverseasOrder( + isBuy: Boolean, + exchangeCode: String, // NASD (나스닥), NYSE (뉴욕), AMEX (아멕스) 등 + stockCode: String, // 종목코드 (예: AAPL) + orderQty: String, // 주문 수량 + orderPrice: String, // 주문 단가 + orderType: String = "00" // 00: 지정가 (거래소별 상이할 수 있음) + ): Result { + return try { + val config = KisSession.config + var pureAccount = config.accountNo.replace("-", "").trim() + if (pureAccount.length == 8) pureAccount += "01" + val cano = pureAccount.take(8) + val acntPrdtCd = pureAccount.takeLast(2) + + // 매수/매도에 따른 TR_ID 설정 (실전 계좌 기준) + val trId = if (isBuy) "TTTT1002U" else "TTTT1006U" + // ※ 모의투자일 경우 매수: VTTT1002U, 매도: VTTT1001U 등 확인 필요 + + val response = client.post("https://openapi.koreainvestment.com:9443/uapi/overseas-stock/v1/trading/order") { + header("authorization", "Bearer ${config.tradeToken}") + header("appkey", config.realAppKey) + header("appsecret", config.realSecretKey) + header("tr_id", trId) + header("Content-Type", "application/json; charset=utf-8") + + setBody(mapOf( + "CANO" to cano, + "ACNT_PRDT_CD" to acntPrdtCd, + "OVRS_EXCG_CD" to exchangeCode, + "PDNO" to stockCode, + "ORD_QTY" to orderQty, + "OVRS_ORD_UNPR" to orderPrice, + "ORD_SVR_DVSN_CD" to "00", // 주문서버구분코드 (보통 00) + "ORD_DVSN" to orderType + )) + } + + if (response.status.isSuccess()) { + val body = response.body() + if (body.rt_cd == "0") { + Result.success(body) + } else { + Result.failure(Exception("주문 실패: ${body.msg1} (${body.msg_cd})")) + } + } else { + Result.failure(Exception("HTTP 에러: ${response.status}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + } \ No newline at end of file diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt index b879570..3871fc5 100644 --- a/src/main/kotlin/network/KisWebSocketManager.kt +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -39,7 +39,7 @@ object KisWebSocketManager { } install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL // ALL, HEADERS, BODY, INFO, NONE 중 선택 + level = LogLevel.NONE // ALL, HEADERS, BODY, INFO, NONE 중 선택 } install(HttpTimeout) { @@ -82,7 +82,7 @@ object KisWebSocketManager { suspend fun connect() { if (isConnected.get()) return - val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:31000" else "ops.koreainvestment.com:21000" + val url = "ops.koreainvestment.com:21000" connectJob?.cancelAndJoin() connectJob = scope.launch { while (isActive) { // 재연결을 위한 루프 추가 diff --git a/src/main/kotlin/network/NewsService.kt b/src/main/kotlin/network/NewsService.kt index 6f3de54..742c1b5 100644 --- a/src/main/kotlin/network/NewsService.kt +++ b/src/main/kotlin/network/NewsService.kt @@ -43,7 +43,7 @@ object NewsService { } install(Logging) { logger = Logger.DEFAULT - level = LogLevel.BODY + level = LogLevel.NONE } } diff --git a/src/main/kotlin/report/LocalReportGenerator.kt b/src/main/kotlin/report/LocalReportGenerator.kt index 098a3d3..b04d7e2 100644 --- a/src/main/kotlin/report/LocalReportGenerator.kt +++ b/src/main/kotlin/report/LocalReportGenerator.kt @@ -74,7 +74,7 @@ object LocalReportGenerator { ) var lastSavedTime = -1L - val reportOpenTime = 60 * 1000 * 60 + val reportOpenTime = 2L * 60L * 60L * 1000L fun generateAndOpenAsync( summary: RawSummaryData, rawHoldings: List, diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index af495bf..1a15297 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -87,7 +87,7 @@ object AutoTradingManager { val nowDate = LocalDate.now(seoulZone) var checkTime = 60_000 * 3L val isTradingDay = nowDate.dayOfWeek.value in 1..5 - if (isTradingDay && now.isAfter(H07M50) && now.isBefore(H18) && !shouldShowFullWindow) { + if (isTradingDay && now.isAfter(KisSession.startTime()) && now.isBefore(KisSession.endTime()) && !shouldShowFullWindow) { shouldShowFullWindow = true SystemSleepPreventer.wakeDisplay() } else if (now.isAfter(LocalTime.of(23, 50)) && now.isBefore(LocalTime.of(8, 0))) { @@ -104,7 +104,7 @@ object AutoTradingManager { val globalCallback = { completeTradingDecision: TradingDecision?, isSuccess: Boolean -> val seoulZone = ZoneId.of("Asia/Seoul") val now = LocalTime.now(ZoneId.of("Asia/Seoul")) - if (now.isBefore(H15M30) && now.isAfter(H08M45) && isSuccess && completeTradingDecision != null) { + if (KisSession.isAvailBuyTime(now) && isSuccess && completeTradingDecision != null) { val decision = completeTradingDecision // 1. 이미 AI가 결정한 decision과 confidence를 신뢰함 @@ -520,7 +520,7 @@ object AutoTradingManager { ) } } else { - if (KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0 + if (KisSession.config.stop_Loss && holding != null && holding.quantity.toInt() > 0 && holding.availOrderCount.toInt() > 0 && holding.profitRate.toDouble() <= KisSession.config.getValues(ConfigIndex.LOSS_MINRATE) @@ -528,10 +528,19 @@ object AutoTradingManager { && holding.valuationProfitAmount.toDouble() >= KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)) { println("${holding.name} ${holding.profitRate.toDouble()} ${holding.valuationProfitAmount.toDouble()} ${KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY)} , ${KisSession.config.getValues(ConfigIndex.LOSS_MINRATE)} , ${KisSession.config.getValues(ConfigIndex.STOP_LOSS)}") val profit = holding.profitRate.toDouble() + tradeService.postOrder( + stockCode = holding.code, + qty = holding.availOrderCount, + price = "0", + isBuy = false, + ).onSuccess { newOrderNo -> + println("✅ [보유 주식 손절 처리] 수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도.") + }.onFailure { + } TradingLogStore.addNotice( "보유주식[${holding.name}]", holding.code, - "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함." + "수익률($profit%) -> ${holding.valuationProfitAmount} 손해 중이며 현제 손절 가이드에 적합함 시장가 매도." ) } analyzeDeepLossHoldingsAfterMarket(holding , true) @@ -545,13 +554,12 @@ object AutoTradingManager { private suspend fun analyzeDeepLossHoldingsAfterMarket(holding: UnifiedStockHolding, isForce : Boolean = false) { // 💡 [신규 추가] 수익률이 크게 마이너스인 종목(-5.0% 이하) 심층 가이드 분석 val now = LocalTime.now() val currentMinute = now.minute - if ((!isForce && (now.hour == 8 || now.hour == 16 || now.hour == 17)) || (isForce && (currentMinute == 0 ))) { + if ((!isForce && (now.hour == 8 || now.hour == 16 || now.hour == 17)) || (isForce && (currentMinute % 5 == 0))) { val profit = holding.profitRate.toDouble() val lossThreshold = -5.0 // 가이드를 작동시킬 손실 기준선 (필요시 ConfigIndex 로 빼셔도 좋습니다) if (profit <= lossThreshold) { println("🔍 [손실 종목 분석] ${holding.name} (수익률: $profit%) - 가이드 산출 중...") - // 차트 데이터 빠르게 가져오기 (일봉 위주로 큰 추세만 확인) val dailyData = KisTradeService.fetchPeriodChartData(holding.code, "D", true).getOrNull() if (!dailyData.isNullOrEmpty()) { @@ -583,6 +591,7 @@ object AutoTradingManager { "보유주식[${holding.name}]", holding.code, "수익률 심각($profit%) -> $advice", + holding.quantity.toInt() ) } // 🟡 [관망] 어정쩡하게 물려있는 상태 @@ -594,9 +603,6 @@ object AutoTradingManager { "수익률($profit%) -> $advice", false ) } - - // 분석 결과를 UI 로그에 띄워 대표님이 확인할 수 있게 함 - } } else { // -5% 이내의 자잘한 손실은 별도 분석 없이 조용히 넘기거나 약식 로그만 남김 @@ -658,12 +664,8 @@ object AutoTradingManager { var currentTimeMillis = System.currentTimeMillis() var waitTime = 0.2 val H15M30 = LocalTime.of(15, 30) - val H16 = LocalTime.of(16, 0) - val H18 = LocalTime.of(18, 0) - val H20 = LocalTime.of(20, 0) - val H08M00 = LocalTime.of(8, 0) val H08M45 = LocalTime.of(8, 45) - val H07M50 = LocalTime.of(7, 50) + private fun runDiscoveryLoop(callback: TradingDecisionCallback) { discoveryJob = scope.launch { println("🚀 [AutoTrading] 발굴 루프 시작: ${LocalDateTime.now()}") @@ -673,10 +675,10 @@ object AutoTradingManager { currentTimeMillis = System.currentTimeMillis() lastTickTime.set(System.currentTimeMillis()) // 생존 신고 when { - now.isAfter(H20) || now.isBefore(H07M50) -> { + now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime()) -> { prepareMarketOpen(now) } - now.isBefore(H20) && now.isAfter(H08M00) -> { + now.isBefore(KisSession.endTime()) && now.isAfter(KisSession.startTime()) -> { waitTime = 0.2 if (now.isAfter(LocalTime.of(8, 0)) && now.isBefore(LocalTime.of(15, 30))) { if (!KisSession.isMarketTokenValid() || !KisSession.isTradeTokenValid()) { @@ -690,7 +692,7 @@ object AutoTradingManager { } withTimeout(CYCLE_TIMEOUT) { println("⏱️ [Cycle Start] ${LocalTime.now()}") - if (now.isAfter(H20)) { + if (now.isAfter(KisSession.endTime())) { executeClosingLiquidation(KisTradeService) } else { executeMarketLoop() @@ -713,7 +715,7 @@ object AutoTradingManager { } suspend fun prepareMarketOpen(now : LocalTime) { - if (now.isAfter(H20) || now.isBefore(H07M50)) { + if (now.isAfter(KisSession.endTime()) || now.isBefore(KisSession.startTime())) { println("🌙 [System] 마감 시간 도달. 자원 정리 후 대기 모드(설정 화면)로 전환합니다.") onMarketClosed?.invoke() RagService.clearDailyCache() @@ -724,7 +726,7 @@ object AutoTradingManager { isSystemReadyToday = false shouldShowFullWindow = false stopDiscovery() // 발굴 루프 완전 폭파 (내일 8시 30분에 다시 켜짐) - } else if (now.isAfter(H07M50) && now.isBefore(H08M00) && !isSystemReadyToday) { + } else if (now.isAfter(KisSession.startTime().minusMinutes(10)) && now.isBefore(KisSession.startTime()) && !isSystemReadyToday) { if (MarketUtil.canTradeToday()) { SystemSleepPreventer.wakeDisplay() shouldShowFullWindow = true @@ -756,19 +758,72 @@ object AutoTradingManager { if (isMorning) { currentBalance = KisTradeService.fetchIntegratedBalance().getOrNull() currentBalance?.let { currentBalance -> - if (LocalTime.now().isBefore(LocalTime.of(16,2))) { + if (LocalTime.now().isBefore(LocalTime.of(18,1))) { TradingReportManager.recordAssetSnapshot( - if (LocalTime.now().isAfter(LocalTime.of(17, 59)) + if (LocalTime.now().isAfter(LocalTime.of(18, 0)) ) SnapshotType.END else SnapshotType.MIDDLE, currentBalance, "" ) } } if (KisSession.config.take_profit) currentBalance?.let { resumePendingSellOrders(KisTradeService, it) } + if (KisSession.tradeConfig.auto_cancel_pending_buy) { + checkAndCancelPendingBuyOrders() + } } else { } } + suspend fun checkAndCancelPendingBuyOrders( + ) { + // 1. 미체결 내역 조회 + val unfilledResult = KisTradeService.fetchUnfilledOrders() + unfilledResult.onSuccess { response -> + val currentTime = System.currentTimeMillis() + + // 매수 주문('02')만 필터링 + response.filter { it.sll_buy_dvsn_cd == "02" }.forEach { order -> + + val orderTimeMillis = parseOrderTime(order.ord_tmd) + val elapsedMillis = currentTime - orderTimeMillis + + // 조건 A: 설정된 대기 시간 경과 여부 + if (elapsedMillis >= KisSession.tradeConfig.auto_cancel_pending_time) { + + // 2. 현재가 조회 (가격을 비교하기 위해) + val currentPrice = KisTradeService.fetchCurrentPrice(order.pdno).getOrNull()?.stck_prpr?.toDouble() ?: 0.0 + val orderedPrice = order.ord_unpr.toDoubleOrNull() ?: 0.0 + + // 조건 B: 현재가와 주문가의 괴리율 체크 (현재가가 너무 올라갔거나 내려갔을 때) + val priceGap = Math.abs(currentPrice - orderedPrice) / orderedPrice + println("checkAndCancelPendingBuyOrders order $order ${elapsedMillis / 1000L}초 ${priceGap}% 차이") + if (priceGap >= KisSession.tradeConfig.auto_cancel_pending_rate) { + TradingLogStore.addNotice(order.prdt_name,order.pdno,"[주문 취소] ${order.prdt_name} (${order.pdno}) - 시간경과 및 가격괴리(${String.format("%.2f", priceGap * 100)}%)로 취소 시도") + KisTradeService.cancelOrder( + order.ord_no, // 원주문번호 + order.pdno + ) + } + } + } + } + } + + // 주문 시간 문자열을 Millis로 변환하는 유틸리티 (당일 주문 기준) + fun parseOrderTime(ordTmd: String): Long { + return try { + val now = java.time.LocalDateTime.now() + val hour = ordTmd.substring(0, 2).toInt() + val min = ordTmd.substring(2, 4).toInt() + val sec = ordTmd.substring(4, 6).toInt() + val orderDateTime = now.withHour(hour).withMinute(min).withSecond(sec) + orderDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + } catch (e: Exception) { + System.currentTimeMillis() + } + } + + suspend fun executeMarketLoop() { myOredsAndBalanceCodes.clear() checkBalance() @@ -822,7 +877,7 @@ object AutoTradingManager { while (iterator.hasNext()) { totalCount-- val stock = iterator.next() - if (now.isBefore(H15M30) && now.isAfter(H08M45)) { + if (KisSession.isAvailBuyTime(now)) { if (BLACKLISTEDSTOCKCODES.contains(stock.code)) { println("❌ 차단 처리된 주식 : ${stock.name}") } else { @@ -860,7 +915,7 @@ object AutoTradingManager { checkBalance() lastForceCheckMinute = currentMinute // 실행 완료 기록 } - } else if ((now.hour == 8 || (now.hour >= 16 && now.hour < 20)) && (currentMinute % 2 == 1)) { + } else if (((now.hour == 8 && KisSession.tradeConfig.before_nxt) || (now.hour >= 16 && now.hour < 20 && KisSession.tradeConfig.after_nxt)) && (currentMinute % 2 == 1)) { if (lastForceCheckMinute != currentMinute) { TradingLogStore.addAnalyzer( " - ", diff --git a/src/main/kotlin/service/DynamicNewsScraper.kt b/src/main/kotlin/service/DynamicNewsScraper.kt index 34906ae..f9cbf5c 100644 --- a/src/main/kotlin/service/DynamicNewsScraper.kt +++ b/src/main/kotlin/service/DynamicNewsScraper.kt @@ -327,7 +327,7 @@ object SafeScraper { println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}") } // 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화) - delay(Random.nextLong(500, 1500)) + delay(Random.nextLong(100, 600)) } } } diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index b9e22ea..ca3aac4 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -157,19 +157,6 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) { item { - Row( - modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 - verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 - ){ - Text("투자 방식", style = MaterialTheme.typography.subtitle1) - Spacer(Modifier.width(10.dp)) - RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) }) - Text("실전") - Spacer(Modifier.width(10.dp)) - RadioButton(selected = config.isSimulation, onClick = { config = config.copy() }) - Text("모의") - } - Divider(Modifier.padding(vertical = 10.dp)) OutlinedTextField( value = config.htsId, @@ -199,26 +186,26 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) Spacer(Modifier.height(10.dp)) - // 모의 3종 입력 - Text("모의투자 정보", fontWeight = FontWeight.Bold) - Row( - modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 - verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 - ) { - OutlinedTextField(value = config.vtsAccountNo, onValueChange = { - config = config.copy(vtsAccountNo = it,) - if (it.length >= 8) checkAndLoadConfig(it, false) - }, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f)) - OutlinedTextField( - value = config.vtsAppKey, - onValueChange = { config = config.copy(vtsAppKey = it,) }, - label = { Text("모의 App Key") }, - modifier = Modifier.weight(0.5f) - ) - } - OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) +// // 모의 3종 입력 +// Text("모의투자 정보", fontWeight = FontWeight.Bold) +// Row( +// modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정 +// verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가 +// ) { +// OutlinedTextField(value = config.vtsAccountNo, onValueChange = { +// config = config.copy(vtsAccountNo = it,) +// if (it.length >= 8) checkAndLoadConfig(it, false) +// }, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f)) +// OutlinedTextField( +// value = config.vtsAppKey, +// onValueChange = { config = config.copy(vtsAppKey = it,) }, +// label = { Text("모의 App Key") }, +// modifier = Modifier.weight(0.5f) +// ) +// } +// OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) - Spacer(Modifier.height(10.dp)) +// Spacer(Modifier.height(10.dp)) Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold) OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation()) diff --git a/src/main/kotlin/ui/TradingDecisionLog.kt b/src/main/kotlin/ui/TradingDecisionLog.kt index c868673..de0b80a 100644 --- a/src/main/kotlin/ui/TradingDecisionLog.kt +++ b/src/main/kotlin/ui/TradingDecisionLog.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import model.ConfigIndex import model.KisSession +import network.KisTradeService import network.StockUniverseLoader import service.AutoTradingManager import java.io.File @@ -55,8 +57,12 @@ fun TradingDecisionLog() { val coroutineScope = rememberCoroutineScope() var searchQuery by remember { mutableStateOf("") } var selectedFilters by remember { mutableStateOf(setOf("전체")) } - val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER","NOTICE") + val filterOptions = listOf("전체", "BUY", "SELL", "SETTING","ANALYZER","WATCH","AFTER","NOTICE")//"PASS",,"RETRY""HOLD", var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) } + val tradeConfig by remember { + KisSession.tradeConfig = KisSession.loadTradeConfig() + mutableStateOf(KisSession.tradeConfig) + } LaunchedEffect(AutoTradingManager.llmAnalyser) { llmAnalyser = AutoTradingManager.llmAnalyser } @@ -87,7 +93,7 @@ fun TradingDecisionLog() { } Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { - Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) { + Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White)) { Button( onClick = { coroutineScope.launch { @@ -225,12 +231,43 @@ fun TradingDecisionLog() { } Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray) + if (log.decision.contains("NOTICE") && log.reason.contains("[손절 경고]")) { + Button( + onClick = { + // 종목 코드 추출 로직 (로그 메시지 형식에 따라 조절 필요) + // 예: "[손절 경고] 삼성전자(005930) ..." 형식일 경우 + val stockCode = log.stockName.substringAfter("[").substringBefore("]") + if (stockCode.length == 6) { + log?.stockCode?.let { code -> + coroutineScope.launch { + KisTradeService.postOrder(stockCode = code, qty= log.qty.toString(), price = "", isBuy = false).onSuccess { + TradingLogStore.addSellLog( + "${log.stockName}[${log.stockCode}]", + "시장가", + "SELL", + "수동 손절 주문 완료" + ) + }.onFailure { error -> + + } + } + } + + } + }, + colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier.height(30.dp).padding(start = 8.dp) + ) { + Text("즉시 매도", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + } } } } } } - Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) { + Column(modifier = Modifier.weight(1f).padding(6.dp).fillMaxHeight().background(Color.White)) { LazyVerticalGrid( columns = GridCells.Fixed(6), // 💡 2와 3의 최소공배수인 6열로 통합! horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -431,6 +468,88 @@ fun TradingDecisionLog() { } } } + + Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White).verticalScroll(rememberScrollState())) { + Text("🕒 시간 및 매매 스케줄", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp)) + + Card(elevation = 2.dp, modifier = Modifier.fillMaxWidth().padding(4.dp)) { + Column(modifier = Modifier.padding(12.dp)) { + Text("자동 매매 시간 설정", fontWeight = FontWeight.Bold, color = Color.Blue) + Spacer(Modifier.height(8.dp)) + + // 시작 시간 (HH:mm) + TimeInputRow("자동 시작", tradeConfig.auto_start_time) { tradeConfig.auto_start_time = it + KisSession.saveTradeConfig() } + // 종료 시간 (HH:mm) + TimeInputRow("자동 종료", tradeConfig.auto_end_time) { tradeConfig.auto_end_time = it + KisSession.saveTradeConfig() } + + Divider(Modifier.padding(vertical = 8.dp)) + + // 매수 금지 시간 설정 + TimeInputRow("매수 시작", tradeConfig.start_buy_time) { tradeConfig.start_buy_time = it + KisSession.saveTradeConfig() } + TimeInputRow("매수 종료", tradeConfig.end_buy_time) { tradeConfig.end_buy_time = it + KisSession.saveTradeConfig() } + } + } + + Spacer(Modifier.height(16.dp)) + + Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp)) + + // Boolean 설정들 + SettingSwitchField( + label = "미체결 자동 취소 (매수)", + initialChecked = tradeConfig.auto_cancel_pending_buy, + helperText = "지정 시간 경과 시 미체결 매수 주문 취소", + onCheckedChange = { tradeConfig.auto_cancel_pending_buy = it + KisSession.saveTradeConfig() + } + ) + + SettingInputField( + label = "자동 취소 대기 시간 (분)", + initialValue = (tradeConfig.auto_cancel_pending_time / (60 * 1000)).toString(), + onSave = { + val minutes = it.toLongOrNull() ?: 60L + tradeConfig.auto_cancel_pending_time = minutes * 60 * 1000 + KisSession.saveTradeConfig() + }, + helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소" + ) + + SettingInputField( + label = "자동 취소 갭 비율", + initialValue = (tradeConfig.auto_cancel_pending_rate).toString(), + onSave = { + val rate = it.toDoubleOrNull() ?: 2.0 + tradeConfig.auto_cancel_pending_rate = rate + KisSession.saveTradeConfig() + }, + helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소" + ) + + + SettingSwitchField( + label = "장 전 대체마켓 매도", + initialChecked = tradeConfig.before_nxt, + onCheckedChange = { tradeConfig.before_nxt = it + KisSession.saveTradeConfig() } + ) + SettingSwitchField( + label = "장 후 대체 마켓 매도", + initialChecked = tradeConfig.after_nxt, + onCheckedChange = { tradeConfig.after_nxt = it + KisSession.saveTradeConfig() } + ) + SettingSwitchField( + label = "해외 주식", + initialChecked = tradeConfig.enableOverSea, + onCheckedChange = { tradeConfig.enableOverSea = it + KisSession.saveTradeConfig() } + ) + } } } @@ -730,4 +849,48 @@ fun FilterChipWithRightClick( } } } +} + +/** + * 시:분 입력을 위한 전용 컴포저블 + */ +@Composable +fun TimeInputRow(label: String, initialTime: String, onTimeChange: (String) -> Unit) { + // 💡 화면에 즉시 글자를 보여주기 위한 로컬 상태 + var localText by remember(initialTime) { mutableStateOf(initialTime) } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, modifier = Modifier.width(80.dp), fontSize = 13.sp, fontWeight = FontWeight.Medium) + + OutlinedTextField( + value = localText, + onValueChange = { + // 5글자(HH:mm) 제한 및 입력 제어 + if (it.length <= 5) { + localText = it + } + }, + placeholder = { Text("00:00") }, + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + // 💡 포커스를 잃었을 때(입력 완료 시) 실제 데이터 객체에 저장 + if (!focusState.isFocused) { + onTimeChange(localText) + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Number // 숫자 키패드 유도 + ), + keyboardActions = KeyboardActions( + onDone = { onTimeChange(localText) } + ), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 14.sp) + ) + } } \ No newline at end of file