This commit is contained in:
2026-01-10 18:16:50 +09:00
parent 547a00b139
commit d4770af62f
28 changed files with 2406 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.RealTimeTrade
object AiService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
}
private const val LLM_URL = "http://localhost:8080/completion"
/**
* 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다.
*/
suspend fun fetchAnalysis(
stockName: String,
currentPrice: String,
trades: List<RealTimeTrade>
): String {
// 최근 체결 내역 10개를 텍스트로 요약
val tradeSummary = trades.take(10).joinToString("\n") { trade ->
"- ${trade.time}: ${trade.price}원 (${trade.volume}${if (trade.type.name == "BUY") "매수" else "매도"})"
}
// Gemma에게 전달할 프롬프트 구성
val prompt = """
<start_of_turn>user
당신은 20년 경력의 전문 주식 트레이더이자 데이터 분석가입니다.
다음 데이터를 바탕으로 해당 종목의 현재 '수급 상황'과 '단기 전망'을 분석하여 3줄 이내로 핵심만 말해주세요.
[종목 정보]
- 종목명: $stockName
- 현재가: $currentPrice
[최근 실시간 체결 내역]
$tradeSummary
분석 기준:
1. 매수 체결 비중이 높은지, 매도 체결 비중이 높은지 판단하세요.
2. 대량 체결(고래)의 움직임이 있는지 확인하세요.
3. 단기적으로 진입하기에 적절한 시점인지 조언하세요.
답변은 한국어로, 친절하지만 단호한 전문가 말투를 사용하세요.<end_of_turn>
<start_of_turn>model
""".trimIndent()
return try {
val response = client.post(LLM_URL) {
contentType(ContentType.Application.Json)
setBody(LlamaRequest(prompt = prompt))
}
if (response.status == HttpStatusCode.OK) {
val result: LlamaResponse = response.body()
result.content.trim()
} else {
"AI 서버 응답 오류: ${response.status}"
}
} catch (e: Exception) {
"분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})"
}
}
}
/**
* llama.cpp 서버 요청 데이터 구조
*/
@Serializable
data class LlamaRequest(
val prompt: String,
val n_predict: Int = 256,
val temperature: Double = 0.7,
val stop: List<String> = listOf("<|end_of_turn|>", "<end_of_turn>")
)
/**
* llama.cpp 서버 응답 데이터 구조
*/
@Serializable
data class LlamaResponse(
val content: String
)
+70
View File
@@ -0,0 +1,70 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import model.TokenRequest
import model.TokenResponse
class KisAuthService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정
})
}
// 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능)
install(Logging) {
level = LogLevel.BODY
}
}
private fun getBaseUrl(isSimulation: Boolean): String {
return if (isSimulation) {
"https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨
} else {
"https://openapi.koreainvestment.com:9443"
}
}
suspend fun fetchAccessToken(
appKey: String,
secretKey: String,
isSimulation: Boolean
): Result<TokenResponse> {
return try {
val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP"
val response = client.post(url) {
// 헤더 설정 (매우 중요)
contentType(ContentType.Application.Json)
// 요청 바디 (TokenRequest 객체 전달)
setBody(TokenRequest(
"client_credentials",
appKey,
secretKey
))
}
if (response.status == HttpStatusCode.OK) {
Result.success(response.body())
} else {
val errorBody = response.bodyAsText()
println("HTTP ${response.status}: $errorBody")
Result.failure(Exception("HTTP ${response.status}: $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
+320
View File
@@ -0,0 +1,320 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import model.AppConfig
import model.CandleData
import model.ChartResponse
import model.OverseasChartResponse
import model.OverseasRankingResponse
import model.RankingResponse
import model.RankingStock
import model.RankingType
import model.StockBalanceResponse
class KisTradeService(private val isSimulation: Boolean) {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY
}
}
suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/pdy-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/pdy-rank"
println("📡 [REQ] 국내 전일 등락 조회: $url")
val response = client.get(url) {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "HHPST01710000")
header("custtype", "P")
header("Content-Type", "application/json; charset=utf-8") // 헤더 명시
parameter("fid_cond_mrkt_div_code", "J")
parameter("fid_cond_scr_div_code", "20171")
parameter("fid_input_iscd", "0000")
parameter("fid_rank_sort_cls_code", "0")
parameter("fid_input_cntstr_value", "")
parameter("fid_prc_cls_code", "1")
}
if (response.status != HttpStatusCode.OK) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}: $errorBody"))
}
val body = response.body<RankingResponse>()
Result.success(body.output.take(20))
} catch (e: Exception) {
println("❌ [ERR] 국내 전일 등락 실패: ${e.message}")
Result.failure(e)
}
}
/**
* [2] 국내 실시간 마켓 랭킹 (장중용)
* TR ID: FHPST01700000
*/
suspend fun fetchMarketRanking(
token: String,
config: AppConfig,
type: RankingType,
isDomestic: Boolean
): Result<List<RankingStock>> {
if (!isDomestic) return Result.failure(Exception("Domestic only"))
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank"
println("📡 [REQ] 국내 실시간 랭킹 조회: $url")
val response = client.get(url) {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "FHPST01700000")
header("custtype", "P")
header("Content-Type", "application/json; charset=utf-8")
parameter("fid_cond_mrkt_div_code", "J")
parameter("fid_cond_scr_div_code", "20170")
parameter("fid_input_iscd", "0000")
parameter("fid_div_cls_code", "0")
parameter("fid_rank_sort_cls_code", type.code)
parameter("fid_etc_cls_code", "0")
}
if (response.status != HttpStatusCode.OK) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}"))
}
val body = response.body<RankingResponse>()
Result.success(body.output.take(20))
} catch (e: Exception) {
println("❌ [ERR] 실시간 랭킹 실패: ${e.message}")
Result.failure(e)
}
}
private val prodBaseUrl = "https://openapi.koreainvestment.com:9443"
// 해외 실시간/전일 등락 상위
suspend fun fetchOverseasRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
return try {
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/rank-fluctuation") {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "HHDFS76240000")
parameter("EXCD", "NAS") // 나스닥 기준
parameter("GUBN", "0") // 상승률순
}
val body = response.body<OverseasRankingResponse>()
Result.success(body.output.map { it.toRankingStock() }.take(20))
} catch (e: Exception) { Result.failure(e) }
}
private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443"
else "https://openapi.koreainvestment.com:9443"
suspend fun fetchBalance(
token: String,
appKey: String,
appSecret: String,
accountNo: String
): Result<StockBalanceResponse> {
return try {
val cleanAccount = accountNo.filter { it.isDigit() }
if (cleanAccount.length != 10) {
return Result.failure(Exception("계좌번호 10자리를 입력해주세요."))
}
val cano = cleanAccount.take(8)
val acntCd = cleanAccount.takeLast(2)
// 웹 소스(KisApiService.kt) 54행 로직 적용
// 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님)
val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R"
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", trId)
header("custtype", "P")
// 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntCd)
parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN
parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN
parameter("INQR_DVSN", "02")
parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
parameter("PRCS_DVSN", "00")
parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "")
}
if (response.status == HttpStatusCode.OK) {
val body = response.body<StockBalanceResponse>()
if (body.rt_cd == "0") {
Result.success(body)
} else {
Result.failure(Exception("API 에러: ${body.msg1} (코드:${body.rt_cd})"))
}
} else {
Result.failure(Exception("HTTP 오류: ${response.status}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String
): Result<ChartResponse> {
return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", "FHKST03010100") // 국내주식 기간별 시세 TR ID
header("custtype", "P")
parameter("FID_COND_SCR_DIV_CODE", "16.4")
parameter("FID_INPUT_ISCD", stockCode)
parameter("FID_INPUT_DATE_1", "20240101") // 시작일 (예시)
parameter("FID_INPUT_DATE_2", "20260110") // 종료일
parameter("FID_PERIOD_DIV_CODE", "D") // 일봉
parameter("FID_ORG_ADJ_PRC", "0") // 수정주가 반영
}
Result.success(response.body())
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? {
return try {
val response = client.post("$baseUrl/oauth2/Approval") {
header("Content-Type", "application/json")
setBody(mapOf("grant_type" to "client_credentials", "appkey" to appKey, "secretkey" to appSecret))
}
// 응답에서 approval_key만 추출 (실제 모델 정의 필요)
val json = response.body<Map<String, String>>()
json["approval_key"]
} catch (e: Exception) {
null
}
}
suspend fun fetchOverseasChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String,
excd: String = "NAS" // 기본 나스닥
): Result<List<CandleData>> {
return try {
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/inquire-daily-chartprice") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", "HHDFS76240000") // 해외 주식 기간별 시세 TR ID
header("custtype", "P")
parameter("EXCD", excd)
parameter("SYMB", stockCode)
parameter("GUBN", "0") // 0: 일봉, 1: 주봉, 2: 월봉
parameter("BYMD", "") // 공백 시 현재일 기준
parameter("MODP", "Y") // 수정주가 반영
}
val body = response.body<OverseasChartResponse>()
// 해외 데이터를 공통 CandleData 형식으로 변환하여 차트 컴포저블 재사용
val converted = body.output2.map {
CandleData(
stck_bsop_date = it.xy_date,
stck_oprc = it.open,
stck_hgpr = it.high,
stck_lwpr = it.low,
stck_clpr = it.last,
acml_vol = it.t_vol
)
}.reversed()
Result.success(converted)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun postOrder(
token: String,
config: AppConfig,
stockCode: String,
qty: String,
price: String, // "0"이면 시장가
isBuy: Boolean
): Result<String> {
return try {
val cleanAccount = config.accountNo.filter { it.isDigit() }
val trId = if (config.isSimulation) {
if (isBuy) "VTRP0001U" else "VTRP0002U" // 모의: 매수/매도
} else {
if (isBuy) "TTTC0802U" else "TTTC0801U" // 실전: 매수/매도
}
val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-cash") {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", trId)
header("Content-Type", "application/json")
setBody(mapOf(
"CANO" to cleanAccount.take(8),
"ACNT_PRDT_CD" to cleanAccount.takeLast(2),
"PDNO" to stockCode,
"ORD_DVSN" to if (price == "0") "01" else "00", // 01:시장가, 00:지정가
"ORD_QTY" to qty,
"ORD_UNPR" to price
))
}
val body = response.body<Map<String, Any>>()
if (body["rt_cd"] == "0") {
Result.success("주문 성공: ${body["msg1"]}")
} else {
Result.failure(Exception("${body["msg1"]}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,131 @@
package network
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow
import model.RealTimeTrade
import model.TradeType
class KisWebSocketManager(private val isSimulation: Boolean) {
val client = HttpClient(CIO) {
install(WebSockets) {
// 타임아웃 설정 (필요 시)
pingInterval = 20_000
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림
socketTimeoutMillis = 15_000
}
}
private var session: DefaultClientWebSocketSession? = null
// Coroutine 관리용 스코프 정의
private val scope = CoroutineScope(Dispatchers.Default + Job())
// UI에서 관찰할 상태값들
val currentPrice = mutableStateOf("0")
val priceChangeColor = mutableStateOf(Color.Transparent)
val tradeLogs = mutableStateListOf<RealTimeTrade>() // 실시간 체결 내역 리스트
suspend fun connect(approvalKey: String) {
val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com"
val port = if (isSimulation) 21001 else 21000
scope.launch {
try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
session = this
// 서버로부터 오는 메시지 수신 루프
incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) {
parseTradeData(frame.readText())
}
}
}
} catch (e: Exception) {
println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}")
e.printStackTrace()
}
}
}
private fun parseTradeData(data: String) {
// 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
val parts = data.split("|")
if (parts.size > 3) {
val rows = parts[3].split("^")
if (rows.size > 15) {
val newTrade = RealTimeTrade(
time = rows[1].chunked(2).joinToString(":"), // HHMMSS -> HH:MM:SS
price = rows[2],
change = rows[4],
volume = rows[12],
type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL
)
// 메인 스레드에서 UI 상태 업데이트
CoroutineScope(Dispatchers.Main).launch {
tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로
if (tradeLogs.size > 30) tradeLogs.removeLast()
// 현재가 및 색상 업데이트 로직 포함 가능
currentPrice.value = newTrade.price
}
}
}
}
private fun updatePriceWithEffect(newPrice: String) {
val oldPrice = currentPrice.value.replace(",", "").toIntOrNull() ?: 0
val current = newPrice.toIntOrNull() ?: 0
currentPrice.value = String.format("%, d", current)
priceChangeColor.value = when {
current > oldPrice -> Color.Red.copy(alpha = 0.2f)
current < oldPrice -> Color.Blue.copy(alpha = 0.2f)
else -> Color.Transparent
}
}
suspend fun subscribeStock(stockCode: String) {
val session = session ?: return
// 이전 구독이 있다면 해지 로직이 필요할 수 있으나,
// 기본적으로 새로운 종목 구독 메시지를 전송합니다.
val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장)
val requestJson = """
{
"header": {
"approval_key": "$approvalKey",
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": "H0STCNT0",
"tr_key": "$stockCode"
}
}
}
""".trimIndent()
try {
session.send(Frame.Text(requestJson))
// 기존 체결 로그 초기화
tradeLogs.clear()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,53 @@
package network
import java.io.File
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlinx.coroutines.*
object LlamaServerManager {
private var process: Process? = null
private val scope = CoroutineScope(Dispatchers.IO + Job())
fun startServer(binPath: String, modelPath: String) {
if (process != null) return // 이미 실행 중이면 무시
val command = listOf(
binPath,
"-m", modelPath,
"--port", "8080",
"-c", "2048", // 컨텍스트 길이
"-t", "4", // 인텔 맥 코어 수에 맞춰 스레드 제한 (부하 방지)
"--embedding" // 나중에 유사도 분석 등을 위해 활성화
)
scope.launch {
try {
val pb = ProcessBuilder(command)
// 실행 파일 권한 확인 (자동 부여)
File(binPath).setExecutable(true)
process = pb.start()
println("✅ AI 서버 시작됨: http://localhost:8080")
// 서버 로그 모니터링 (에러 디버깅용)
val reader = BufferedReader(InputStreamReader(process?.inputStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
// 서버 준비 완료 로그 확인용
if (line?.contains("HTTP server listening") == true) {
println("🚀 AI 모델 로딩 완료 및 대기 중")
}
}
} catch (e: Exception) {
println("❌ AI 서버 실행 실패: ${e.message}")
}
}
}
fun stopServer() {
process?.destroy()
process = null
println("🛑 AI 서버 종료")
}
}