...
This commit is contained in:
parent
a3a0338cc5
commit
2c736e687c
@ -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 {
|
||||
|
||||
@ -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,17 +521,19 @@ object TradingLogStore {
|
||||
fun addAnalyzer(name : String, code : String, log: String, positive : Boolean = false) {
|
||||
synchronized(this) {
|
||||
if (decisionLogs.size > 1000) decisionLogs.removeAt(0)
|
||||
if (positive) {
|
||||
decisionLogs.add(
|
||||
LogEntry(
|
||||
time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
|
||||
stockName = "$name[$code] 분석",
|
||||
decision = if(positive) "ANALYZER" else "PASS",
|
||||
decision = if (positive) "ANALYZER" else "PASS",
|
||||
confidence = 100.0,
|
||||
reason = log
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addNotice(name : String, code : String, log: String) {
|
||||
synchronized(this) {
|
||||
@ -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) {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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 // 자기자본이익률
|
||||
)
|
||||
212
src/main/kotlin/network/KisOverseasService.kt
Normal file
212
src/main/kotlin/network/KisOverseasService.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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) { // 재연결을 위한 루프 추가
|
||||
|
||||
@ -43,7 +43,7 @@ object NewsService {
|
||||
}
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.BODY
|
||||
level = LogLevel.NONE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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(
|
||||
" - ",
|
||||
|
||||
@ -327,7 +327,7 @@ object SafeScraper {
|
||||
println("❌ [스크래핑 실패] ${item.originallink}: ${e.localizedMessage}")
|
||||
}
|
||||
// 기사 사이의 짧은 휴식 (차단 방지 및 브라우저 안정화)
|
||||
delay(Random.nextLong(500, 1500))
|
||||
delay(Random.nextLong(100, 600))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -731,3 +850,47 @@ 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user