213 lines
7.9 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|