.
This commit is contained in:
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 서버 종료")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user