atrade/src/main/kotlin/network/KisOverseasService.kt
2026-04-29 17:56:16 +09:00

213 lines
7.9 KiB
Kotlin

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", "FzvO_2P679KGRDUVx3u7rkMvCvcqiynu")
}.body()
if (response.isNotEmpty()) {
Result.success(response[0])
} else {
Result.failure(Exception("데이터가 없습니다."))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}