This commit is contained in:
lunaticbum 2026-04-29 15:32:09 +09:00
parent a3a0338cc5
commit 2c736e687c
13 changed files with 857 additions and 104 deletions

View File

@ -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 {

View File

@ -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) {

View File

@ -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())
}
}

View File

@ -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<OverseasStockHolding> = 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<OverseasAnalysisItem> = 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<OverseasPriceOutput2> = 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 // 자기자본이익률
)

View File

@ -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<T>(
val rt_cd: String, // 0: 성공, 이외 실패
val msg_cd: String,
val msg1: String,
val output: T? = null,
val output1: OverseasPriceOutput1? = null, // 시세 조회용
val output2: List<OverseasPriceOutput2> = 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<List<OverseasAnalysisItem>> {
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<OverseasResponse<List<OverseasAnalysisItem>>>()
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<OverseasOrderOutput> {
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<OverseasResponse<OverseasOrderOutput>>()
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<OverseasPriceOutput2> {
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<OverseasResponse<Unit>>()
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<OverseasAnalysisItem>()
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<KeyMetrics> {
return try {
val response: List<KeyMetrics> = 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)
}
}
}

View File

@ -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<UnifiedStockHolding>()
@ -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<CurrentPriceOutput> {
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<CurrentPriceResponse>()
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<StockBalanceResponse> {
// 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름)
return Result.failure(Exception("Not Implemented"))
suspend fun fetchOverseasBalance(
exchangeCode: String = "NASD", // NASD: 나스닥, NYSE: 뉴욕, AMEX: 아멕스 등
currencyCode: String = "USD"
): Result<OverseasBalanceResponse> {
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<OverseasBalanceResponse>()
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<OverseasOrderResponse> {
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<OverseasOrderResponse>()
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)
}
}
}

View File

@ -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) { // 재연결을 위한 루프 추가

View File

@ -43,7 +43,7 @@ object NewsService {
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.BODY
level = LogLevel.NONE
}
}

View File

@ -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<RawHoldingData>,

View File

@ -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(
" - ",

View File

@ -327,7 +327,7 @@ object SafeScraper {
println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}")
}
// 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화)
delay(Random.nextLong(500, 1500))
delay(Random.nextLong(100, 600))
}
}
}

View File

@ -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())

View File

@ -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)
)
}
}