This commit is contained in:
lunaticbum 2026-01-13 16:04:25 +09:00
parent d4770af62f
commit 2bb94e2856
23 changed files with 1406 additions and 1103 deletions

View File

@ -27,6 +27,9 @@ dependencies {
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-logging:${ktorVersion}") implementation("io.ktor:ktor-client-logging:${ktorVersion}")
implementation("ch.qos.logback:logback-classic:1.4.11")
// Database (Exposed & SQLite) // Database (Exposed & SQLite)
// H2 Database (네이티브 라이브러리 없는 순수 자바 DB) // H2 Database (네이티브 라이브러리 없는 순수 자바 DB)
implementation("com.h2database:h2:2.2.224") implementation("com.h2database:h2:2.2.224")

View File

@ -3,21 +3,14 @@ import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import kotlinx.coroutines.launch
import network.KisAuthService
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import androidx.compose.runtime.*
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import model.AppConfig import model.AppConfig
import model.KisSession
import network.LlamaServerManager import network.LlamaServerManager
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import ui.DashboardScreen import ui.DashboardScreen
import ui.SettingsScreen import ui.SettingsScreen
@ -25,58 +18,58 @@ import ui.SettingsScreen
enum class AppScreen { Settings, Dashboard } enum class AppScreen { Settings, Dashboard }
fun main() = application { fun main() = application {
// 앱 경로 기준 리소스 위치 설정 // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
val binPath = "./src/main/resources/bin/llama-server" val binPath = "./src/main/resources/bin/llama-server"
val modelPath = "./src/main/resources/models/gemma-2-9b-it-Q4_K_M.gguf"
LaunchedEffect(Unit) {
LlamaServerManager.startServer(binPath, modelPath)
}
Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매") { Window(onCloseRequest = ::exitApplication, title = "KIS AI 자동매매") {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) } var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
// 1. 초기 상태를 null로 두어 로딩 전임을 표시 var isLoaded by remember { mutableStateOf(false) }
var savedConfig by remember { mutableStateOf<AppConfig?>(null) } val scope = rememberCoroutineScope()
var token by remember { mutableStateOf("") }
var selectedStockCode by remember { mutableStateOf<String?>(null) }
// 앱 시작 시 DB에서 마지막 설정 로드 // 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
DatabaseFactory.init() DatabaseFactory.init()
val loaded = transaction { transaction {
ConfigTable.selectAll().lastOrNull()?.let { ConfigTable.selectAll().lastOrNull()?.let {
AppConfig( KisSession.config = AppConfig(
appKey = it[ConfigTable.appKey], realAppKey = it[ConfigTable.realAppKey],
secretKey = it[ConfigTable.secretKey], realSecretKey = it[ConfigTable.realSecretKey],
accountNo = it[ConfigTable.accountNo], realAccountNo = it[ConfigTable.realAccountNo],
vtsAppKey = it[ConfigTable.vtsAppKey],
vtsSecretKey = it[ConfigTable.vtsSecretKey],
vtsAccountNo = it[ConfigTable.vtsAccountNo],
isSimulation = it[ConfigTable.isSimulation], isSimulation = it[ConfigTable.isSimulation],
modelPath = it[ConfigTable.modelPath] modelPath = it[ConfigTable.modelPath]
) )
} }
} }
// 로드된 값이 있으면 업데이트, 없으면 기본 객체 생성 isLoaded = true
savedConfig = loaded ?: AppConfig()
} }
// savedConfig가 로드될 때까지 기다림 (깜빡임 방지 및 데이터 보장) if (!isLoaded) {
if (savedConfig == null) { // 로딩 중 표시
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator()
CircularProgressIndicator()
}
} else { } else {
when (currentScreen) { when (currentScreen) {
AppScreen.Settings -> { AppScreen.Settings -> {
SettingsScreen( SettingsScreen(
initialConfig = savedConfig!!, // !! 사용하여 null 아님을 보장 onAuthSuccess = {
onAuthSuccess = { config, accessToken -> // 2. 설정 및 인증 완료 시점의 처리
savedConfig = config val config = KisSession.config
token = accessToken
// LLM 서버 시작 (설정된 모델 경로 사용)
if (config.modelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.modelPath)
}
// 대시보드로 화면 전환
currentScreen = AppScreen.Dashboard currentScreen = AppScreen.Dashboard
} }
) )
} }
AppScreen.Dashboard -> { AppScreen.Dashboard -> {
DashboardScreen(config = savedConfig!!, token = token) // 이제 모든 서비스는 KisSession.config를 전역 참조함
DashboardScreen()
} }
} }
} }

View File

@ -1,3 +1,4 @@
import model.AppConfig
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.javatime.datetime import org.jetbrains.exposed.sql.javatime.datetime
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@ -7,13 +8,17 @@ import java.time.LocalDateTime
// 1. 앱 설정 테이블 // 1. 앱 설정 테이블
object ConfigTable : Table("app_config") { object ConfigTable : Table("app_config") {
val id = integer("id").autoIncrement() val id = integer("id").autoIncrement()
val appKey = varchar("app_key", 255) val realAppKey = varchar("real_app_key", 255).default("")
val secretKey = varchar("secret_key", 255) val realSecretKey = varchar("real_secret_key", 255).default("")
val accountNo = varchar("account_no", 20) val realAccountNo = varchar("real_account_no", 20).default("")
val isSimulation = bool("is_simulation") val vtsAppKey = varchar("vts_app_key", 255).default("")
val modelPath = varchar("model_path", 512).default("") // 이 라인이 있어야 합니다. val vtsSecretKey = varchar("vts_secret_key", 255).default("")
val vtsAccountNo = varchar("vts_account_no", 20).default("")
val isSimulation = bool("is_simulation").default(true)
val modelPath = varchar("model_path", 512).default("")
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
// 2. 거래 내역 테이블 (대량 데이터용) // 2. 거래 내역 테이블 (대량 데이터용)
object TradeLogTable : Table("trade_logs") { object TradeLogTable : Table("trade_logs") {
val id = long("id").autoIncrement() val id = long("id").autoIncrement()
@ -56,14 +61,40 @@ object DatabaseFactory {
} }
} }
// fun fetchRecentLogs(limit: Int = 50): List<TradeLog> { fun findConfigByAccount(accountNo: String): AppConfig? {
// return transaction { return transaction {
// TradeLogTable.selectAll() ConfigTable.select {
// .orderBy(TradeLogTable.timestamp to SortOrder.DESC) (ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
// .limit(limit) }.lastOrNull()?.let {
// .map { AppConfig(
// // ResultRow를 객체로 변환 realAppKey = it[ConfigTable.realAppKey],
// } realSecretKey = it[ConfigTable.realSecretKey],
// } realAccountNo = it[ConfigTable.realAccountNo],
// } vtsAppKey = it[ConfigTable.vtsAppKey],
vtsSecretKey = it[ConfigTable.vtsSecretKey],
vtsAccountNo = it[ConfigTable.vtsAccountNo],
isSimulation = it[ConfigTable.isSimulation],
modelPath = it[ConfigTable.modelPath]
)
}
}
}
fun saveConfig(config: AppConfig) {
transaction {
// 기존 설정을 모두 지우고 최신 설정 하나만 유지
ConfigTable.deleteAll()
ConfigTable.insert {
it[realAppKey] = config.realAppKey
it[realSecretKey] = config.realSecretKey
it[vtsAppKey] = config.vtsAppKey
it[vtsSecretKey] = config.vtsSecretKey
it[realAccountNo] = config.realAccountNo
it[vtsAccountNo] = config.vtsAccountNo
it[isSimulation] = config.isSimulation
it[modelPath] = config.modelPath
}
}
}
} }

View File

@ -1,11 +1,52 @@
package model package model
import kotlinx.serialization.Serializable import java.time.LocalDateTime
data class AppConfig( data class AppConfig(
val appKey: String = "", // [DB 저장 데이터]
val secretKey: String = "", // 실전 3종
val accountNo: String = "", val realAppKey: String = "",
val realSecretKey: String = "",
val realAccountNo: String = "",
// 모의 3종
val vtsAppKey: String = "",
val vtsSecretKey: String = "",
val vtsAccountNo: String = "",
// [세션 데이터 - 메모리에서만 관리]
var marketToken: String = "",
var marketTokenExpiredAt: LocalDateTime? = null, // 만료 시간 추가
var tradeToken: String = "",
var tradeTokenExpiredAt: LocalDateTime? = null,
var websocketToken: String = "",
val isSimulation: Boolean = true, val isSimulation: Boolean = true,
val modelPath: String = "" // 추가된 필드 val modelPath: String = "") {
)
val accountNo : String
get() {
return if (isSimulation) vtsAccountNo else realAccountNo
}
}
// [신규] 전역에서 참조할 단일 세션 객체
object KisSession {
var config: AppConfig = AppConfig()
// 시장 데이터 토큰 유효성 검사 (만료 5분 전부터는 유효하지 않은 것으로 간주)
fun isMarketTokenValid(): Boolean {
return config.marketToken.isNotEmpty() &&
config.marketTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false
}
// 매매용 토큰 유효성 검사
fun isTradeTokenValid(): Boolean {
return config.tradeToken.isNotEmpty() &&
config.tradeTokenExpiredAt?.isAfter(LocalDateTime.now().plusMinutes(5)) ?: false
}
}

View File

@ -17,5 +17,5 @@ data class TokenResponse(
val access_token: String, val access_token: String,
val access_token_token_expired: String? = null, val access_token_token_expired: String? = null,
val token_type: String? = null, val token_type: String? = null,
val expires_in: Int? = null val expires_in: Long = 0L
) )

View File

@ -22,7 +22,7 @@ data class CandleData(
val stck_hgpr: String, // 고가 val stck_hgpr: String, // 고가
val stck_lwpr: String, // 저가 val stck_lwpr: String, // 저가
val stck_clpr: String, // 종가 val stck_clpr: String, // 종가
val acml_vol: String // 누적 거래량 val acml_vol: String ="" // 누적 거래량
) )
@Serializable @Serializable
data class OverseasCandleData( data class OverseasCandleData(

View File

@ -1,5 +1,6 @@
package model package model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@ -30,28 +31,53 @@ data class BalanceSummary(
) )
@Serializable @Serializable
data class RankingResponse( data class RankingResponse(
var rt_cd : String,
var msg1 : String,
var msg_cd : String,
val output1: List<RankingStock> = emptyList(),
val output: List<RankingStock> = emptyList() val output: List<RankingStock> = emptyList()
) ) {
val list = output + output1 + emptyList()
enum class RankingType(val code: String, val title: String) {
RISE("0", "상승"),
FALL("1", "하락"),
VOLUME("2", "거래량"),
AMOUNT("3", "금액"),
OVERTIME("4", "시간외"), // 웹 소스의 시간외 상승 TR 연동
SHORT_HOT("5", "단기추천") // 단기 과열 및 추천 종목
} }
enum class RankingType(
val title: String,
val trId: String,
val scrNo: String,
val path: String,
val sortCode: String // 추가: 각 TR ID에 맞는 정렬 코드
) {
VOLUME("거래량순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "0"),
VALUE("거래대금순", "FHPST01710000", "20171", "/uapi/domestic-stock/v1/quotations/volume-rank", "3"),
RISE("상승률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "0"),
FALL("하락률순", "FHPST01700000", "20170", "/uapi/domestic-stock/v1/ranking/fluctuation", "1"),
// MARKET_CAP("시가총액순", "FHPST01740000", "20174", "/uapi/domestic-stock/v1/quotations/market-cap", "0"),
// HTS_TOP20("HTS조회상위", "HHMCM000100C0", "20175", "/uapi/domestic-stock/v1/ranking/hts-top-view", "0"),
// 링크로 전달주신 추가 기능 보완
VOLUME_POWER("체결강도순", "FHPST01680000", "20168", "/uapi/domestic-stock/v1/ranking/volume-power", "0"),
// BEFORE("장전예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "0"),
// AFTER("장후예상", "FHPST01820000", "20182", "/uapi/domestic-stock/v1/ranking/exp-trans-updown", "1")
}
@Serializable @Serializable
data class RankingStock( data class RankingStock(
val hts_kor_isnm: String = "", // 종목명
val hts_kor_alph_nm: String = "", // 종목명 val hts_kor_alph_nm: String = "", // 종목명
val mkrtc_objt_iscd: String = "", // 종목코드 val mkrtc_objt_iscd: String = "", // 종목코드
val mksc_shrn_iscd: String = "", // 종목코드
val stck_prpr: String = "0", // 현재가 val stck_prpr: String = "0", // 현재가
val prdy_ctrt: String = "0.0" // 등락률 val prdy_ctrt: String = "0.0", // 등락률
) val mrkt_div_cls_code : String = "J",
) {
val name : String
get() = hts_kor_isnm ?: hts_kor_alph_nm ?: mkrtc_objt_iscd ?: ""
val code : String
get() = mksc_shrn_iscd ?: mkrtc_objt_iscd ?: hts_kor_isnm ?: ""
}
@Serializable @Serializable
data class OverseasRankingResponse( data class OverseasRankingResponse(
val rt_cd: String = "",
val msg1: String = "",
val output: List<OverseasRankingStock> = emptyList() val output: List<OverseasRankingStock> = emptyList()
) )
@ -70,4 +96,23 @@ data class OverseasRankingStock(
stck_prpr = last, stck_prpr = last,
prdy_ctrt = rate prdy_ctrt = rate
) )
} }
@Serializable
data class UnifiedStockHolding(
val code: String, // 종목코드
val name: String, // 종목명
val quantity: String, // 보유수량
val avgPrice: String, // 매입단가
val currentPrice: String, // 현재가
val profitRate: String, // 수익률
val evalAmount: String, // 평가금액
val isDomestic: Boolean // 국내/해외 구분
)
@Serializable
data class UnifiedBalance(
val totalAsset: String, // 총 평가자산
val totalProfitRate: String, // 총 수익률
val holdings: List<UnifiedStockHolding> // 통합 보유 종목 리스트
)

View File

@ -4,65 +4,83 @@ import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.* 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.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import io.ktor.client.plugins.contentnegotiation.* import kotlinx.coroutines.async
import io.ktor.client.plugins.logging.* import kotlinx.coroutines.coroutineScope
import model.KisSession
import model.TokenRequest import model.TokenRequest
import model.TokenResponse import model.TokenResponse
import java.time.LocalDateTime
class KisAuthService { class KisAuthService {
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정 encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
}) })
} }
// 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능) // [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) { install(Logging) {
level = LogLevel.BODY logger = Logger.DEFAULT
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
} }
} }
private fun getBaseUrl(isSimulation: Boolean): String { private fun getBaseUrl(isSimulation: Boolean) =
return if (isSimulation) { if (isSimulation) "https://openapivts.koreainvestment.com:29443"
"https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨 else "https://openapi.koreainvestment.com:9443"
/**
* 실전(시세용) 매매(모의/실전 선택) 토큰을 모두 갱신합니다.
*/
suspend fun refreshAllTokens(): Boolean = coroutineScope {
val config = KisSession.config
// 1. 실전 시세용 토큰 발급 (Market Token)
val marketTokenJob = async { fetchAccessToken(config.realAppKey, config.realSecretKey, false) }
// 2. 매매용 토큰 발급 (Trade Token - 설정에 따라 VTS 또는 Real 사용)
val tradeTokenJob = async {
if (config.isSimulation) fetchAccessToken(config.vtsAppKey, config.vtsSecretKey, true)
else marketTokenJob.await() // 실전 매매면 시세용 토큰과 동일함
}
val mResult = marketTokenJob.await()
val tResult = tradeTokenJob.await()
if (mResult.isSuccess && tResult.isSuccess) {
val mData = mResult.getOrThrow()
val tData = tResult.getOrThrow()
// KisSession 업데이트
KisSession.config = KisSession.config.copy(
marketToken = mData.access_token,
marketTokenExpiredAt = LocalDateTime.now().plusSeconds(mData.expires_in),
tradeToken = tData.access_token,
tradeTokenExpiredAt = LocalDateTime.now().plusSeconds(tData.expires_in),
)
true
} else { } else {
"https://openapi.koreainvestment.com:9443" false
} }
} }
suspend fun fetchAccessToken( private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
appKey: String,
secretKey: String,
isSimulation: Boolean
): Result<TokenResponse> {
return try { return try {
val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP" val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
val response = client.post(url) {
// 헤더 설정 (매우 중요)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(TokenRequest("client_credentials", appKey, secretKey))
// 요청 바디 (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"))
} }
if (response.status == HttpStatusCode.OK) Result.success(response.body())
else Result.failure(Exception("인증 실패: ${response.status}"))
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }

View File

@ -9,167 +9,333 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import model.AppConfig
import model.CandleData import model.CandleData
import model.ChartResponse
import model.OverseasChartResponse
import model.OverseasRankingResponse
import model.RankingResponse import model.RankingResponse
import model.RankingStock import model.RankingStock
import model.RankingType import model.RankingType
import model.StockBalanceResponse import model.StockBalanceResponse
class KisTradeService(private val isSimulation: Boolean) { import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import model.*
class KisTradeService {
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true }) json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
} }
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) { install(Logging) {
logger = Logger.DEFAULT logger = Logger.DEFAULT
level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
} }
} }
private val prodUrl = "https://openapi.koreainvestment.com:9443"
suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result<List<RankingStock>> { private val vtsUrl = "https://openapivts.koreainvestment.com:29443"
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] 국내 실시간 마켓 랭킹 (장중용) * [1] 통합 잔고 조회 (국내 + 해외 합산)
* TR ID: FHPST01700000
*/ */
suspend fun fetchMarketRanking( suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
token: String, val config = KisSession.config
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 domesticJob = async { fetchDomesticRawBalance() }
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank" val overseasJob = async { fetchOverseasRawBalance() }
println("📡 [REQ] 국내 실시간 랭킹 조회: $url")
val response = client.get(url) { try {
header("authorization", "Bearer $token") val domRes = domesticJob.await().getOrNull()
header("appkey", config.appKey) val ovsRes = overseasJob.await().getOrNull()
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") val combinedHoldings = mutableListOf<UnifiedStockHolding>()
parameter("fid_cond_scr_div_code", "20170")
parameter("fid_input_iscd", "0000") // 국내 종목 매핑
parameter("fid_div_cls_code", "0") domRes?.output1?.forEach {
parameter("fid_rank_sort_cls_code", type.code) combinedHoldings.add(UnifiedStockHolding(
parameter("fid_etc_cls_code", "0") 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 = true
))
} }
if (response.status != HttpStatusCode.OK) { // 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑)
val errorBody = response.bodyAsText() ovsRes?.output1?.forEach {
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody") combinedHoldings.add(UnifiedStockHolding(
return Result.failure(Exception("HTTP ${response.status}")) 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
))
} }
val body = response.body<RankingResponse>() val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
Result.success(body.output.take(20)) (ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
} catch (e: Exception) {
println("❌ [ERR] 실시간 랭킹 실패: ${e.message}") Result.success(UnifiedBalance(
Result.failure(e) totalAsset = String.format("%,d", totalAmt),
} totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
} holdings = combinedHoldings
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) } } catch (e: Exception) { Result.failure(e) }
} }
/**
* [통합 순위 조회] 국내/해외 분기 처리
*/
suspend fun fetchMarketRanking(type: RankingType, isDomestic: Boolean): Result<List<RankingStock>> {
return if (isDomestic) {
fetchDomesticRanking(type)
} else {
fetchOverseasRanking(type)
}
}
/**
private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443" * [국내 주식 순위] 명세서 기반 파라미터 최적화
else "https://openapi.koreainvestment.com:9443" */
private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> {
suspend fun fetchBalance( val config = KisSession.config
token: String,
appKey: String,
appSecret: String,
accountNo: String
): Result<StockBalanceResponse> {
return try { return try {
val cleanAccount = accountNo.filter { it.isDigit() } val response = client.get("$prodUrl${type.path}") {
if (cleanAccount.length != 10) { header("authorization", "Bearer ${config.marketToken}")
return Result.failure(Exception("계좌번호 10자리를 입력해주세요.")) header("appkey", config.realAppKey)
header("appsecret", config.realSecretKey)
header("tr_id", type.trId)
header("custtype", "P")
parameter("FID_COND_MRKT_DIV_CODE", "J")
parameter("FID_COND_SCR_DIV_CODE", type.scrNo)
parameter("FID_INPUT_ISCD", "0000") // 전체 시장
parameter("FID_DIV_CLS_CODE", "0") // 전체
parameter("FID_ETC_CLS_CODE", "0")
parameter("FID_PRC_CLS_CODE", "0")
when(type) {
RankingType.VALUE -> {
parameter("FID_BLNG_CLS_CODE", type.sortCode)
}
RankingType.VOLUME -> {
parameter("FID_BLNG_CLS_CODE",type.sortCode)
}
RankingType.FALL -> {
parameter("FID_RANK_SORT_CLS_CODE", type.sortCode)
}
RankingType.RISE -> {
parameter("FID_RANK_SORT_CLS_CODE", type.sortCode)
}
// RankingType.AFTER -> {
// parameter("FID_MKOP_CLS_CODE", type.sortCode)
// }
// RankingType.BEFORE -> {
// parameter("FID_MKOP_CLS_CODE", type.sortCode)
// }
else -> {
}
}
parameter("FID_PBMN", "")
parameter("FID_APLY_RANG_PRC_1", "")
parameter("FID_TRGT_CLS_CODE", "11111111")
parameter("FID_TRGT_EXLS_CLS_CODE", "000000")
parameter("FID_RSFL_RATE2", "")
parameter("FID_RSFL_RATE1", "")
parameter("FID_INPUT_CNT_1", "0")
parameter("FID_INPUT_PRICE_1", "")
parameter("FID_INPUT_PRICE_2", "")
parameter("FID_VOL_CNT", "")
parameter("FID_INPUT_DATE_1", "")
// 상승/하락률 순위(HHPST01710000)일 경우 추가 파라미터
if (type.trId == "HHPST01710000") {
parameter("fid_diff_div_code", "00") // 00: 전일 대비
}
} }
val cano = cleanAccount.take(8) val body = response.body<RankingResponse>()
val acntCd = cleanAccount.takeLast(2) if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1))
} catch (e: Exception) { Result.failure(e) }
}
// 웹 소스(KisApiService.kt) 54행 로직 적용 /**
// 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님) * [해외 주식 순위] 모델 매핑 오류 수정
val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R" */
private suspend fun fetchOverseasRanking(type: RankingType): Result<List<RankingStock>> {
val config = KisSession.config
val path = "/uapi/overseas-stock/v1/quotations/rank-fluctuation"
val trId = "HHDFS76240000"
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { return try {
header("authorization", "Bearer $token") val response = client.get("$prodUrl$path") {
header("appkey", appKey) header("authorization", "Bearer ${config.marketToken}")
header("appsecret", appSecret) header("appkey", config.realAppKey)
header("appsecret", config.realSecretKey)
header("tr_id", trId) header("tr_id", trId)
header("custtype", "P") header("custtype", "P")
// 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅 parameter("EXCD", "NAS") // 기본 나스닥
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntCd) val gubn = when (type) {
parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN RankingType.RISE -> "0"
parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN RankingType.FALL -> "1"
RankingType.VOLUME -> "2"
RankingType.VALUE -> "3"
else -> "0"
}
parameter("GUBN", gubn)
}
// [수정] OverseasRankingResponse로 정확히 파싱 후 변환
val body = response.body<OverseasRankingResponse>()
if (body.rt_cd == "0") {
Result.success(body.output.map { it.toRankingStock() })
} else {
Result.failure(Exception("해외 랭킹 에러: ${body.msg1}"))
}
} catch (e: Exception) { Result.failure(e) }
}
/**
* [3] 통합 주문 (지정가/시장가 매수/매도)
*/
suspend fun postOrder(
stockCode: String,
qty: String,
price: String, // "0" 이면 시장가
isBuy: Boolean
): Result<String> {
val config = KisSession.config
val isDomestic = stockCode.length == 6 && stockCode.all { it.isDigit() }
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = when {
isDomestic && config.isSimulation -> if (isBuy) "VTRP0001U" else "VTRP0002U"
isDomestic && !config.isSimulation -> if (isBuy) "TTTC0802U" else "TTTC0801U"
!isDomestic && config.isSimulation -> if (isBuy) "VTTT3001U" else "VTTT3002U"
else -> if (isBuy) "TTTS3001U" else "TTTS3002U"
}
return try {
val response = client.post("$baseUrl/uapi/${if(isDomestic) "domestic" else "overseas"}-stock/v1/trading/order-cash") {
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", trId)
header("Content-Type", "application/json")
setBody(mapOf(
"CANO" to config.accountNo.take(8),
"ACNT_PRDT_CD" to config.accountNo.takeLast(2),
"PDNO" to stockCode,
"ORD_DVSN" to if (price == "0") "01" else "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) }
}
/**
* [4] 웹소켓 승인키(Approval Key) 발급
*/
suspend fun refreshWebsocketKey(): Boolean {
val config = KisSession.config
return try {
val response = client.post("$prodUrl/oauth2/Approval") {
header("Content-Type", "application/json")
setBody(mapOf("grant_type" to "client_credentials", "appkey" to config.realAppKey, "secretkey" to config.realSecretKey))
}
if (response.status == HttpStatusCode.OK) {
val approvalKey = response.body<Map<String, String>>()["approval_key"]
if (approvalKey != null) {
KisSession.config = KisSession.config.copy(websocketToken = approvalKey)
true
} else false
} else false
} catch (e: Exception) { false }
}
/**
* [5] 차트 데이터 조회 (일봉 기준)
*/
suspend fun fetchChartData(stockCode: String, isDomestic: Boolean): Result<List<CandleData>> {
val config = KisSession.config
// 국내 주식 분봉 조회 TR ID: FHKST03010200
val trId = if (isDomestic) "FHKST03010200" else "HHDFS76240000"
val path = if (isDomestic)
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
else "/uapi/overseas-stock/v1/quotations/inquire-time-itemchartprice"
return try {
val response = client.get("$prodUrl$path") {
header("authorization", "Bearer ${config.marketToken}")
header("appkey", config.realAppKey)
header("appsecret", config.realSecretKey)
header("tr_id", trId)
header("custtype", "P")
header("content-type", "application/json; charset=utf-8")
parameter("FID_ETC_CLS_CODE", "")
parameter("FID_COND_MRKT_DIV_CODE", "J")
parameter("FID_INPUT_ISCD", stockCode)
parameter("FID_INPUT_HOUR_1", "153000") // 장 마감 시간까지
parameter("FID_PW_DATA_INCU_YN", "Y") // 전일 데이터 포함 여부
}
// API 응답에서 output2(캔들 리스트)를 CandleData로 변환 (역순으로 오므로 reverse 필요)
val body = response.body<JsonObject>()
val output2 = body["output2"]?.jsonArray
val candles = output2?.map { element ->
val obj = element.jsonObject
CandleData(
stck_bsop_date = obj["stck_bsop_date"]?.jsonPrimitive?.content ?: "",
stck_clpr = obj["stck_prpr"]?.jsonPrimitive?.content ?: "0", // 분봉/시간 데이터는 stck_prpr이 종가
stck_oprc = obj["stck_oprc"]?.jsonPrimitive?.content ?: "0",
stck_hgpr = obj["stck_hgpr"]?.jsonPrimitive?.content ?: "0",
stck_lwpr = obj["stck_lwpr"]?.jsonPrimitive?.content ?: "0",
acml_vol = obj["cntg_vol"]?.jsonPrimitive?.content ?: "0" // 필수 필드 누락 방지
)
}?.reversed() ?: emptyList()
Result.success(candles)
} catch (e: Exception) { Result.failure(e) }
}
// --- 내부 Raw 호출용 (통합 잔고에서 사용) ---
private suspend fun fetchDomesticRawBalance(): Result<StockBalanceResponse> {
val config = KisSession.config
val baseUrl = if (config.isSimulation) vtsUrl else prodUrl
val trId = if (config.isSimulation) "VTTC8434R" else "TTTC8434R"
return try {
val response = client.get("$baseUrl/uapi/domestic-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", trId)
parameter("CANO", config.accountNo.take(8))
parameter("ACNT_PRDT_CD", config.accountNo.takeLast(2))
parameter("AFHR_FLPR_YN", "N")
parameter("OFL_YN", "N")
parameter("INQR_DVSN", "02") parameter("INQR_DVSN", "02")
parameter("UNPR_DVSN", "01") parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N") parameter("FUND_STTL_ICLD_YN", "N")
@ -178,143 +344,12 @@ class KisTradeService(private val isSimulation: Boolean) {
parameter("CTX_AREA_FK100", "") parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "") 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()) Result.success(response.body())
} catch (e: Exception) { } catch (e: Exception) { Result.failure(e) }
Result.failure(e)
}
} }
suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? { private suspend fun fetchOverseasRawBalance(): Result<StockBalanceResponse> {
return try { // 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름)
val response = client.post("$baseUrl/oauth2/Approval") { return Result.failure(Exception("Not Implemented"))
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)
}
} }
} }

View File

@ -6,45 +6,62 @@ import androidx.compose.ui.graphics.Color
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
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.plugins.websocket.* import io.ktor.client.plugins.websocket.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.websocket.* import io.ktor.websocket.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import model.AppConfig
import model.KisSession
import model.RealTimeTrade import model.RealTimeTrade
import model.TradeType import model.TradeType
class KisWebSocketManager(private val isSimulation: Boolean) { class KisWebSocketManager {
val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
install(WebSockets) { install(WebSockets) {
// 타임아웃 설정 (필요 시)
pingInterval = 20_000 pingInterval = 20_000
} }
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 15_000 requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림 connectTimeoutMillis = 15_000
socketTimeoutMillis = 15_000 socketTimeoutMillis = 15_000
} }
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
}
} }
private var session: DefaultClientWebSocketSession? = null
// Coroutine 관리용 스코프 정의 private var session: DefaultClientWebSocketSession? = null
private val scope = CoroutineScope(Dispatchers.Default + Job()) private val scope = CoroutineScope(Dispatchers.Default + Job())
// UI에서 관찰 상태값 // UI 관찰 상태값
val currentPrice = mutableStateOf("0") val currentPrice = mutableStateOf("0")
val priceChangeColor = mutableStateOf(Color.Transparent) val priceChangeColor = mutableStateOf(Color.Transparent)
val tradeLogs = mutableStateListOf<RealTimeTrade>() // 실시간 체결 내역 리스트 val tradeLogs = mutableStateListOf<RealTimeTrade>()
suspend fun connect() {
val config = KisSession.config
val approvalKey = config.websocketToken
suspend fun connect(approvalKey: String) { if (approvalKey.isEmpty()) {
val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com" println("⚠️ 웹소켓 승인키가 없습니다. 먼저 발급받아야 합니다.")
val port = if (isSimulation) 21001 else 21000 return
}
// 시세 데이터는 항상 실전 서버(21000)를 권장합니다.
val hostUrl = "ops.koreainvestment.com"
val port = 21000
scope.launch { scope.launch {
try { try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") { client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
session = this session = this
// 서버로부터 오는 메시지 수신 루프 println("✅ 웹소켓 연결 성공")
incoming.consumeAsFlow().collect { frame -> incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) { if (frame is Frame.Text) {
parseTradeData(frame.readText()) parseTradeData(frame.readText())
@ -52,8 +69,7 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}") println("❌ 웹소켓 연결 오류: ${e.localizedMessage}")
e.printStackTrace()
} }
} }
} }
@ -96,19 +112,38 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
} }
} }
/**
* [2] 실시간 시세 구독 (Registration)
* tr_type = "1" (등록)
*/
suspend fun subscribeStock(stockCode: String) { suspend fun subscribeStock(stockCode: String) {
val session = session ?: return sendRequest(stockCode, trType = "1")
println("📡 실시간 시세 구독 시작: $stockCode")
}
// 이전 구독이 있다면 해지 로직이 필요할 수 있으나, /**
// 기본적으로 새로운 종목 구독 메시지를 전송합니다. * [3] 실시간 시세 구독 취소 (Unsubscription)
val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장) * tr_type = "2" (해제)
*/
suspend fun unsubscribeStock(stockCode: String) {
if (stockCode.isEmpty()) return
sendRequest(stockCode, trType = "2")
println("🚫 실시간 시세 구독 해제: $stockCode")
}
/**
* 공통 요청 전송 함수
*/
private suspend fun sendRequest(stockCode: String, trType: String) {
val currentSession = session ?: return
val config = KisSession.config
val requestJson = """ val requestJson = """
{ {
"header": { "header": {
"approval_key": "$approvalKey", "approval_key": "${config.websocketToken}",
"custtype": "P", "custtype": "P",
"tr_type": "1", "tr_type": "$trType",
"content-type": "utf-8" "content-type": "utf-8"
}, },
"body": { "body": {
@ -118,14 +153,12 @@ class KisWebSocketManager(private val isSimulation: Boolean) {
} }
} }
} }
""".trimIndent() """.trimIndent()
try { try {
session.send(Frame.Text(requestJson)) currentSession.send(Frame.Text(requestJson))
// 기존 체결 로그 초기화
tradeLogs.clear()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() println("❌ 웹소켓 요청 실패 ($trType): ${e.localizedMessage}")
} }
} }
} }

View File

@ -10,7 +10,7 @@ object LlamaServerManager {
private val scope = CoroutineScope(Dispatchers.IO + Job()) private val scope = CoroutineScope(Dispatchers.IO + Job())
fun startServer(binPath: String, modelPath: String) { fun startServer(binPath: String, modelPath: String) {
if (process != null) return // 이미 실행 중이면 무시 if (process != null || modelPath.isNullOrBlank()) return // 이미 실행 중이면 무시
val command = listOf( val command = listOf(
binPath, binPath,

View File

@ -21,25 +21,26 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.KisSession
import model.RealTimeTrade import model.RealTimeTrade
import network.AiService import network.AiService
@Composable @Composable
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTimeTrade>) { fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
var aiOpinion by remember { mutableStateOf("분석 대기 중...") } var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// 1. 모델 경로 유효성 체크 // KisSession의 전역 설정을 참조
val isModelConfigured = remember { val isModelConfigured = remember(KisSession.config.modelPath) {
val path = util.AppConfigManager.modelPath val path = KisSession.config.modelPath
path.isNotEmpty() && java.io.File(path).exists() path.isNotEmpty() && java.io.File(path).exists()
} }
Card( Card(
elevation = 2.dp, elevation = 2.dp,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE),
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE) modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -49,38 +50,22 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTim
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
// 2. 경로가 정상일 때만 버튼 활성화
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
isLoading = true isLoading = true
aiOpinion = "Gemma가 데이터를 읽고 있습니다..." aiOpinion = "데이터 분석 중..."
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades) aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
isLoading = false isLoading = false
} }
}, },
enabled = isModelConfigured && !isLoading, // 유효성 체크 반영 enabled = isModelConfigured && !isLoading
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.White,
disabledBackgroundColor = Color(0xFFE0E0E0)
)
) { ) {
Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp) Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp)
} }
} }
Divider(Modifier.padding(vertical = 8.dp))
if (!isModelConfigured) { Text(text = aiOpinion, style = MaterialTheme.typography.body2)
Text(
"설정에서 .gguf 모델 파일을 먼저 등록해주세요.",
color = Color.Red,
fontSize = 11.sp,
modifier = Modifier.padding(top = 4.dp)
)
} else {
Divider(Modifier.padding(vertical = 8.dp))
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
}
} }
} }
} }

View File

@ -0,0 +1,146 @@
// src/main/kotlin/ui/BalanceSection.kt
package ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.UnifiedBalance
import network.KisTradeService
@Composable
fun BalanceSection(
tradeService: KisTradeService,
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
) {
var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
var isLoading by remember { mutableStateOf(false) }
// 화면 진입 시 및 갱신 시 데이터 로드
LaunchedEffect(Unit) {
isLoading = true
tradeService.fetchIntegratedBalance().onSuccess {
balanceData = it
}.onFailure {
println("❌ 잔고 로드 실패: ${it.localizedMessage}")
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "나의 자산",
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// 1. 자산 요약 카드
BalanceSummaryCard(balanceData)
Spacer(modifier = Modifier.height(16.dp))
// 2. 통합 보유 종목 리스트
Text(
text = "보유 종목",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
if (isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.weight(1f, true)) {
items(balanceData?.holdings ?: emptyList()) { holding ->
UnifiedStockItemRow(holding) {
onStockSelect(holding.code, holding.name, holding.isDomestic)
}
}
}
}
}
}
@Composable
fun BalanceSummaryCard(summary: UnifiedBalance?) {
Card(
elevation = 2.dp,
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
backgroundColor = androidx.compose.ui.graphics.Color.White
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
Text(
text = "${summary?.totalAsset ?: "0"}",
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold
)
val rate = summary?.totalProfitRate?.toDoubleOrNull() ?: 0.0
val color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
else androidx.compose.ui.graphics.Color.DarkGray
Text(
text = "수익률: ${if (rate > 0) "+" else ""}$rate%",
color = color,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
elevation = 1.dp
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
// 국내/해외 구분 배지
Surface(
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color(0xFFE3F2FD)
else androidx.compose.ui.graphics.Color(0xFFF3E5F5),
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
) {
Text(
text = if (holding.isDomestic) "국내" else "해외",
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontSize = 10.sp,
color = if (holding.isDomestic) androidx.compose.ui.graphics.Color.Blue
else androidx.compose.ui.graphics.Color(0xFF7B1FA2)
)
}
Spacer(Modifier.width(4.dp))
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
}
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
}
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
Text("${holding.currentPrice}")
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%",
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
else androidx.compose.ui.graphics.Color.DarkGray,
fontSize = 12.sp
)
}
}
}
}

View File

@ -22,19 +22,22 @@ fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
val spacing = candleWidth * 0.2f // 캔들 사이 간격 val spacing = candleWidth * 0.2f // 캔들 사이 간격
// 1. 가격 범위 계산 (스케일링용) // 1. 가격 범위 계산 (스케일링용)
val maxPrice = data.maxOf { it.stck_hgpr.toDouble() } val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
val minPrice = data.minOf { it.stck_lwpr.toDouble() } val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
val priceRange = maxPrice - minPrice val priceRange = maxPrice - minPrice
// priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지
fun getY(price: Double): Float { fun getY(price: Double): Float {
if (priceRange == 0.0) return height / 2f
return (height - ((price - minPrice) / priceRange * height)).toFloat() return (height - ((price - minPrice) / priceRange * height)).toFloat()
} }
// 루프 내부에서도 동일하게 적용
data.forEachIndexed { index, candle -> data.forEachIndexed { index, candle ->
val open = candle.stck_oprc.toDouble() val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
val close = candle.stck_clpr.toDouble() val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
val high = candle.stck_hgpr.toDouble() val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
val low = candle.stck_lwpr.toDouble() val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
val isRising = close >= open val isRising = close >= open
val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF) val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)

View File

@ -1,302 +1,78 @@
// src/main/kotlin/ui/DashboardScreen.kt
package ui package ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import model.KisSession
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.RankingType
import model.StockHolding
import network.KisTradeService import network.KisTradeService
import network.KisWebSocketManager import network.KisWebSocketManager
import util.MarketUtil
@Composable @Composable
fun DashboardScreen(config: AppConfig, token: String) { fun DashboardScreen() {
val wsManager = remember { KisWebSocketManager(config.isSimulation) } val tradeService = remember { KisTradeService() }
val tradeService = remember { KisTradeService(config.isSimulation) } val wsManager = remember { KisWebSocketManager() }
// 전역 상태: 현재 선택된 종목 // 전역 상태: 현재 선택된 종목 정보
var selectedStockCode by remember { mutableStateOf("") } var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") }
var isDomestic by remember { mutableStateOf(true) }
// 잔고 데이터 상태 // 초기 웹소켓 연결
var holdings by remember { mutableStateOf<List<StockHolding>>(emptyList()) }
var summary by remember { mutableStateOf<BalanceSummary?>(null) }
// 초기 데이터 로드 및 웹소켓 연결
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val approvalKey = tradeService.fetchApprovalKey(config.appKey, config.secretKey) wsManager.connect()
approvalKey?.let { wsManager.connect(it) }
tradeService.fetchBalance(token, config.appKey, config.secretKey, config.accountNo)
.onSuccess {
holdings = it.output1
summary = it.output2.firstOrNull()
}
} }
// 메인 3분할 레이아웃
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 내 자산 및 통합 잔고
// [좌측 25%] 나의 자산 및 잔고
Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) { Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) {
Text("나의 잔고", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) BalanceSection(tradeService) { code, name, isDom ->
Spacer(modifier = Modifier.height(8.dp))
BalanceSummaryCard(summary)
Spacer(modifier = Modifier.height(8.dp))
MyStockList(holdings) { code, name ->
selectedStockCode = code selectedStockCode = code
selectedStockName = name selectedStockName = name
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
} }
} }
VerticalDivider() VerticalDivider()
// [중앙 45%] 실시간 차트 및 주문 (가장 중요) // [중앙 45%] 실시간 정보 및 주문
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) { Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) {
if (selectedStockCode.isNotEmpty()) { if (selectedStockCode.isNotEmpty()) {
StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager) StockDetailSection(
stockCode = selectedStockCode,
stockName = selectedStockName,
isDomestic = isDomestic,
tradeService = tradeService,
wsManager = wsManager
)
} else { } else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray) Text("분석할 종목을 선택하세요", color = Color.Gray)
} }
} }
} }
VerticalDivider() VerticalDivider()
// [우측 30%] 시장 추천 리스트 (탭 방식) // [우측 30%] 시장 추천 TOP 20 (실전 데이터)
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) { Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
Text("시장 추천 TOP 20", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) MarketSection(tradeService) { code, name, isDom ->
Spacer(modifier = Modifier.height(8.dp))
RecommendationTabs(config, token) { code, name ->
selectedStockCode = code selectedStockCode = code
selectedStockName = name selectedStockName = name
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
} }
} }
} }
} }
@Composable @Composable
fun StockItemRow(stock: StockHolding) { fun VerticalDivider() {
Row( Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stock.prdt_name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(stock.pdno, fontSize = 12.sp, color = Color.Gray)
}
Column(horizontalAlignment = Alignment.End) {
Text("${stock.prpr}", fontWeight = FontWeight.Bold)
// 수익률에 따른 색상 처리 (웹 소스 format.color 로직 이식)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = when {
rate > 0 -> Color(0xFFE03E2D) // 웹 소스의 빨간색
rate < 0 -> Color(0xFF0E62CF) // 웹 소스의 파란색
else -> Color.DarkGray
}
Text(
text = "${if(rate > 0) "▲" else if(rate < 0) "▼" else ""} ${stock.evlu_pfls_rt}%",
color = color,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun RankingItemRow(
index: Int, // 순위 표시를 위해 index 추가
rank: RankingStock,
isDomestic: Boolean,
type: RankingType,
onClick: () -> Unit
) {
val displayColor = when {
type == RankingType.FALL -> Color(0xFF0E62CF) // 하락 탭은 무조건 파랑
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 > 0 -> Color(0xFFE03E2D) // 그 외 양수면 빨강
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 < 0 -> Color(0xFF0E62CF) // 음수면 파랑
else -> Color.DarkGray
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = 0.dp,
backgroundColor = Color.White
) {
Row(
modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// [1] 순위 표시 (1~20)
Text(
text = "${index + 1}",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (index < 3) displayColor else Color.Gray, // 1~3위 강조
modifier = Modifier.width(24.dp)
)
// [2] 종목명 및 코드
Column(modifier = Modifier.weight(1f)) {
Text(
text = rank.hts_kor_alph_nm,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = rank.mkrtc_objt_iscd,
fontSize = 11.sp,
color = Color.Gray
)
}
// [3] 등락률 배지
Surface(
color = displayColor.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rank.prdy_ctrt.toDouble() > 0) "+" else ""}${rank.prdy_ctrt}%",
color = displayColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
@Composable
fun VerticalDivider(modifier: Modifier = Modifier) {
Box(modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}
@Composable
fun BalanceSummaryCard(summary: BalanceSummary?) {
Card(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
backgroundColor = Color(0xFFF8F9FA) // 가벼운 배경색
) {
Column(modifier = Modifier.padding(20.dp)) {
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = Color.Gray)
Text(
text = "${summary?.tot_evlu_amt ?: "0"}",
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(8.dp))
val profitRate = summary?.evlu_pfls_rt?.toDoubleOrNull() ?: 0.0
val profitColor = if (profitRate > 0) Color(0xFFE03E2D) else if (profitRate < 0) Color(0xFF0E62CF) else Color.DarkGray
Row(verticalAlignment = Alignment.CenterVertically) {
Text("실현 수익률: ", style = MaterialTheme.typography.body2)
Text(
text = "${if (profitRate > 0) "+" else ""}$profitRate%",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
color = profitColor
)
}
}
}
}
@Composable
fun StockItemRow(stock: StockHolding, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onClick() },
elevation = 2.dp,
shape = RoundedCornerShape(4.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. 종목명 및 코드 (왼쪽)
Column(modifier = Modifier.weight(1.2f)) {
Text(
text = stock.prdt_name,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stock.pdno,
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// 2. 보유 수량 및 현재가 (중앙)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text("${stock.hldg_qty}", style = MaterialTheme.typography.body2)
Text(
text = "${stock.prpr}",
style = MaterialTheme.typography.caption,
color = Color.DarkGray
)
}
// 3. 수익률 (오른쪽)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
Box(
modifier = Modifier.weight(0.8f),
contentAlignment = Alignment.CenterEnd
) {
Surface(
color = color.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${stock.evlu_pfls_rt}%",
color = color,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
} }

View File

@ -0,0 +1,101 @@
// src/main/kotlin/ui/MarketSection.kt
package ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.RankingStock
import model.RankingType
import network.KisTradeService
@Composable
fun MarketSection(
tradeService: KisTradeService,
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
) {
var selectedTab by remember { mutableStateOf(RankingType.VOLUME) }
var isDomestic by remember { mutableStateOf(true) }
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
// 탭 또는 국가 변경 시 데이터 로드
LaunchedEffect(selectedTab, isDomestic) {
isLoading = true
tradeService.fetchMarketRanking(selectedTab, isDomestic).onSuccess {
rankingList = it
}.onFailure {
rankingList = emptyList()
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
// [1] 상단 타이틀 및 국내/해외 토글
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("시장 랭킹 (TOP 20)", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold)
// 국내/해외 전환 스위치 방식
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (isDomestic) "국내" else "해외", fontSize = 12.sp, fontWeight = FontWeight.Medium)
Switch(
checked = !isDomestic,
onCheckedChange = { isDomestic = !it },
colors = SwitchDefaults.colors(checkedThumbColor = Color(0xFF0E62CF))
)
}
}
// [2] 랭킹 타입 탭 (상승, 하락, 거래량 등)
ScrollableTabRow(
selectedTabIndex = selectedTab.ordinal,
backgroundColor = Color.Transparent,
contentColor = Color.Black,
edgePadding = 0.dp,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTab.ordinal]),
color = Color(0xFFE03E2D)
)
}
) {
RankingType.values().forEach { type ->
Tab(
selected = selectedTab == type,
onClick = { selectedTab = type },
text = { Text(type.title, fontSize = 12.sp) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// [3] 랭킹 리스트
if (isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(strokeWidth = 2.dp)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(rankingList.withIndex().toList()) { (index, stock) ->
MarketStockItemRow(index + 1, stock) {
onStockSelect(stock.code, stock.name, isDomestic)
}
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
}
}
}
}
}

View File

@ -0,0 +1,62 @@
package ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.RankingStock
import model.RankingType
import network.KisTradeService
// src/main/kotlin/ui/MarketStockItemRow.kt
@Composable
fun MarketStockItemRow(
rank: Int,
stock: RankingStock,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 순위 표시
Text(
text = rank.toString(),
modifier = Modifier.width(24.dp),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = if (rank <= 3) Color(0xFFE03E2D) else Color.Gray
)
Column(modifier = Modifier.weight(1f)) {
Text(stock.name, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1)
Text(stock.code, fontSize = 10.sp, color = Color.Gray)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = String.format("%,d", stock.stck_prpr.toLongOrNull() ?: 0L),
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
val rate = stock.prdy_ctrt.toDoubleOrNull() ?: 0.0
Text(
text = "${if (rate > 0) "+" else ""}${stock.prdy_ctrt}%",
fontSize = 11.sp,
color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
)
}
}
}

View File

@ -33,80 +33,61 @@ import kotlin.collections.isNotEmpty
@Composable @Composable
fun OrderSection( fun OrderSection(
config: AppConfig,
token: String,
stockCode: String, stockCode: String,
currentPrice: String, currentPrice: String,
onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달 onOrderResult: (String, Boolean) -> Unit
) { ) {
val scope = rememberCoroutineScope() // 에러 해결: scope 정의 val scope = rememberCoroutineScope()
val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의 val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전
var orderQty by remember { mutableStateOf("1") } var orderQty by remember { mutableStateOf("1") }
var orderPrice by remember { mutableStateOf("0") } // 0은 시장가 var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가
var isSubmitting by remember { mutableStateOf(false) }
Column( Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) {
modifier = Modifier Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp)
.fillMaxWidth()
.background(Color(0xFFF8F9FA)) OutlinedTextField(
.padding(12.dp) value = orderQty,
) { onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
Row(verticalAlignment = Alignment.CenterVertically) { label = { Text("수량") },
// 수량 입력 modifier = Modifier.fillMaxWidth()
OutlinedTextField( )
value = orderQty,
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it }, OutlinedTextField(
label = { Text("수량", fontSize = 10.sp) }, value = if(orderPrice == "0") "시장가" else orderPrice,
modifier = Modifier.width(100.dp).height(50.dp), onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
singleLine = true label = { Text("가격") },
) modifier = Modifier.fillMaxWidth()
Spacer(modifier = Modifier.width(8.dp)) )
// 가격 입력 (시장가 체크박스 기능 포함 가능)
OutlinedTextField(
value = if(orderPrice == "0") "시장가" else orderPrice,
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
label = { Text("가격", fontSize = 10.sp) },
modifier = Modifier.weight(1f).height(50.dp),
singleLine = true
)
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(
// 매수 버튼 onClick = {
Button( scope.launch {
onClick = { // KisSession을 사용하는 postOrder 호출
isSubmitting = true tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true)
scope.launch { .onSuccess { onOrderResult(it, true) }
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, true) .onFailure { onOrderResult(it.message ?: "에러", false) }
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) } }
isSubmitting = false },
} modifier = Modifier.fillMaxWidth(),
}, colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
modifier = Modifier.weight(1f).height(45.dp), ) {
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)), Text("매수", color = Color.White)
enabled = !isSubmitting }
) {
Text("현금매수", color = Color.White, fontWeight = FontWeight.Bold)
}
// 매도 버튼 Button(
Button( onClick = {
onClick = { scope.launch {
isSubmitting = true tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false)
scope.launch { .onSuccess { onOrderResult(it, true) }
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, false) .onFailure { onOrderResult(it.message ?: "에러", false) }
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) } }
isSubmitting = false },
} modifier = Modifier.fillMaxWidth(),
}, colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
modifier = Modifier.weight(1f).height(45.dp), ) {
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)), Text("매도", color = Color.White)
enabled = !isSubmitting
) {
Text("현금매도", color = Color.White, fontWeight = FontWeight.Bold)
}
} }
} }
} }

View File

@ -0,0 +1,43 @@
// src/main/kotlin/ui/RealTimeTradeList.kt
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.RealTimeTrade
@Composable
fun RealTimeTradeList(tradeLogs: List<RealTimeTrade>) {
Column(modifier = Modifier.fillMaxSize()) {
// [1] 리스트 헤더 영역
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFEEEEEE)) // 연한 회색 배경
.padding(vertical = 4.dp)
) {
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp, color = Color.Gray)
}
// [2] 실제 데이터 리스트
LazyColumn(modifier = Modifier.fillMaxSize()) {
// 최신 데이터가 위로 오도록 표시 (이미 tradeLogs에 add(0, new)로 들어옴)
items(tradeLogs) { trade ->
TradeLogRow(trade) // 기존에 만드신 행(Row) 컴포넌트 재사용
Divider(color = Color(0xFFF5F5F5), thickness = 0.5.dp)
}
}
}
}

View File

@ -1,152 +1,152 @@
package ui //package ui
//
//
import androidx.compose.foundation.BorderStroke //import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background //import androidx.compose.foundation.background
import androidx.compose.foundation.clickable //import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* //import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn //import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인 //import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.lazy.itemsIndexed //import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape //import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* //import androidx.compose.material.*
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset //import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.* //import androidx.compose.runtime.*
import io.ktor.client.engine.cio.CIO //import io.ktor.client.engine.cio.CIO
// 아래 두 import가 'delegate' 에러를 해결합니다. //// 아래 두 import가 'delegate' 에러를 해결합니다.
import androidx.compose.runtime.getValue //import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue //import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment //import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier //import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color //import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight //import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow //import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp //import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp //import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch //import kotlinx.coroutines.launch
import model.AppConfig //import model.AppConfig
import model.BalanceSummary //import model.BalanceSummary
import model.RankingStock //import model.RankingStock
import model.RankingType //import model.RankingType
import model.StockHolding //import model.StockHolding
import network.KisTradeService //import network.KisTradeService
import util.MarketUtil //import util.MarketUtil
//
@Composable //@Composable
fun RecommendationTabs( //fun RecommendationTabs(
config: AppConfig, // config: AppConfig,
token: String, // token: String,
onSelect: (String, String) -> Unit // onSelect: (String, String) -> Unit
) { //) {
var isDomestic by remember { mutableStateOf(true) } // var isDomestic by remember { mutableStateOf(true) }
var selectedType by remember { mutableStateOf(RankingType.RISE) } // var selectedType by remember { mutableStateOf(RankingType.RISE) }
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) } // var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
//
val tradeService = remember { KisTradeService(config.isSimulation) } // val tradeService = remember { KisTradeService(config.isSimulation) }
val isKoreaOpen = MarketUtil.isKoreanMarketOpen() // val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가 // var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
//
// 데이터 로드 로직 // // 데이터 로드 로직
LaunchedEffect(isDomestic, selectedType, isKoreaOpen) { // LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
errorMessage = null // 로딩 시작 시 에러 초기화 // errorMessage = null // 로딩 시작 시 에러 초기화
if (isDomestic) { // if (isDomestic) {
if (isKoreaOpen) { // if (isKoreaOpen) {
tradeService.fetchMarketRanking(token, config, selectedType, true) // tradeService.fetchMarketRanking(token, config, selectedType, true)
.onSuccess { rankingList = it.take(20) } // .onSuccess { rankingList = it.take(20) }
.onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." } // .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
} else { // } else {
tradeService.fetchDomesticPreviousDayRanking(token, config) // tradeService.fetchDomesticPreviousDayRanking(token, config)
.onSuccess { rankingList = it } // .onSuccess { rankingList = it }
.onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" } // .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
} // }
} else { // } else {
tradeService.fetchOverseasRanking(token, config) // tradeService.fetchOverseasRanking(token, config)
.onSuccess { rankingList = it } // .onSuccess { rankingList = it }
.onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." } // .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
} // }
} // }
//
Column(modifier = Modifier.fillMaxSize()) { // Column(modifier = Modifier.fillMaxSize()) {
// [1] 국내/해외 전환 버튼 (항상 노출) // // [1] 국내/해외 전환 버튼 (항상 노출)
Row(Modifier.fillMaxWidth().padding(8.dp)) { // Row(Modifier.fillMaxWidth().padding(8.dp)) {
MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true } // MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
Spacer(Modifier.width(8.dp)) // Spacer(Modifier.width(8.dp))
MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false } // MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
} // }
//
// [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출) // // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
ScrollableTabRow( // ScrollableTabRow(
selectedTabIndex = selectedType.ordinal, // selectedTabIndex = selectedType.ordinal,
edgePadding = 8.dp, // edgePadding = 8.dp,
backgroundColor = Color.White, // backgroundColor = Color.White,
indicator = { tabPositions -> // indicator = { tabPositions ->
TabRowDefaults.Indicator( // TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]), // modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF) // color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
) // )
} // }
) { // ) {
RankingType.values().forEach { type -> // RankingType.values().forEach { type ->
Tab( // Tab(
selected = selectedType == type, // selected = selectedType == type,
onClick = { selectedType = type }, // onClick = { selectedType = type },
text = { Text(type.title, fontSize = 12.sp) } // text = { Text(type.title, fontSize = 12.sp) }
) // )
} // }
} // }
//
// [3] 장외 시간 안내 바 // // [3] 장외 시간 안내 바
if (isDomestic && !isKoreaOpen) { // if (isDomestic && !isKoreaOpen) {
Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) { // Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
Text( // Text(
"현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.", // "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
fontSize = 11.sp, modifier = Modifier.padding(8.dp) // fontSize = 11.sp, modifier = Modifier.padding(8.dp)
) // )
} // }
} // }
//
// [4] 추천 리스트 영역 // // [4] 추천 리스트 영역
Box(modifier = Modifier.weight(1f)) { // Box(modifier = Modifier.weight(1f)) {
if (errorMessage != null) { // if (errorMessage != null) {
// 에러 발생 시 안내 // // 에러 발생 시 안내
Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) { // Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
Text(errorMessage!!, color = Color.Gray) // Text(errorMessage!!, color = Color.Gray)
Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") } // Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
} // }
} else if (rankingList.isEmpty()) { // } else if (rankingList.isEmpty()) {
// 로딩 중 // // 로딩 중
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() // CircularProgressIndicator()
} // }
} else { // } else {
// [성공] 리스트 노출 // // [성공] 리스트 노출
LazyColumn { // LazyColumn {
itemsIndexed(rankingList) { index, stock -> // itemsIndexed(rankingList) { index, stock ->
RankingItemRow(index, stock, isDomestic, selectedType) { // RankingItemRow(index, stock, isDomestic, selectedType) {
onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm) // onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
} // }
} // }
} // }
} // }
} // }
} // }
} //}
//
@Composable //@Composable
fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) { //fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
OutlinedButton( // OutlinedButton(
onClick = onClick, // onClick = onClick,
colors = ButtonDefaults.outlinedButtonColors( // colors = ButtonDefaults.outlinedButtonColors(
backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent // backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
), // ),
modifier = Modifier.height(36.dp), // modifier = Modifier.height(36.dp),
border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray) // border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
) { // ) {
Text( // Text(
text = title, // text = title,
color = if (isSelected) activeColor else Color.Gray, // color = if (isSelected) activeColor else Color.Gray,
fontSize = 12.sp, // fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal // fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
) // )
} // }
} //}

View File

@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.DragData import androidx.compose.ui.DragData
@ -14,151 +12,113 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.onExternalDrag import androidx.compose.ui.onExternalDrag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import model.AppConfig import model.AppConfig
import model.KisSession
import network.KisAuthService import network.KisAuthService
import network.KisTradeService
import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.deleteAll
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog // 파일 선택기용
// src/main/kotlin/ui/SettingsScreen.kt
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(onAuthSuccess: () -> Unit) {
initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요
onAuthSuccess: (AppConfig, String) -> Unit
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val authService = remember { KisAuthService() } var config by remember { mutableStateOf(KisSession.config) }
var statusMessage by remember { mutableStateOf("정보를 입력하세요.") }
// 화면 입력 상태값 // 계좌번호 입력 시 데이터 자동 로드 함수
var appKey by remember { mutableStateOf(initialConfig.appKey) } fun checkAndLoadConfig(accountNo: String, isReal: Boolean) {
var secretKey by remember { mutableStateOf(initialConfig.secretKey) } val loaded = DatabaseFactory.findConfigByAccount(accountNo)
var accountNo by remember { mutableStateOf(initialConfig.accountNo) } if (loaded != null) {
var isSimulation by remember { mutableStateOf(initialConfig.isSimulation) } config = loaded
var modelPath by remember { mutableStateOf(initialConfig.modelPath ?: "") } // AI 모델 경로 statusMessage = "✅ 기존 데이터를 불러왔습니다."
}
var statusMessage by remember { mutableStateOf("설정 정보를 입력하세요.") } }
var isLoading by remember { mutableStateOf(false) }
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) { LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
item { item {
Text("API 및 계좌 설정", style = MaterialTheme.typography.h6) Text("거래 방식 선택", style = MaterialTheme.typography.h6)
OutlinedTextField(value = appKey, onValueChange = { appKey = it }, label = { Text("App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = secretKey, onValueChange = { secretKey = it }, label = { Text("Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
OutlinedTextField(value = accountNo, onValueChange = { accountNo = it }, label = { Text("계좌번호") }, modifier = Modifier.fillMaxWidth())
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it }) RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) })
Text("모의투자 서버 사용") Text("실전투자")
Spacer(Modifier.width(16.dp))
RadioButton(selected = config.isSimulation, onClick = { config = config.copy(isSimulation = true) })
Text("모의투자")
} }
Divider(Modifier.padding(vertical = 12.dp))
// 실전 3종 입력
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.realAccountNo, onValueChange = {
config = config.copy(realAccountNo = it)
if(it.length >= 8) checkAndLoadConfig(it, true)
}, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(16.dp))
// 모의 3종 입력
Text("모의투자 정보", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
config = config.copy(vtsAccountNo = it)
if(it.length >= 8) checkAndLoadConfig(it, false)
}, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
// --- 추가된 AI 모델 설정 섹션 --- // AI 모델 경로 및 드래그 앤 드롭
Text("AI 모델 설정 (Gemma-2-9b)", style = MaterialTheme.typography.h6) Text("AI 모델 설정", fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = modelPath,
onValueChange = { modelPath = it },
label = { Text("GGUF 모델 경로") },
modifier = Modifier.weight(1f),
placeholder = { Text("파일을 선택하거나 드래그하세요") }
)
IconButton(onClick = {
val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("GGUF 모델", "gguf")
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
modelPath = chooser.selectedFile.absolutePath
}
}) {
Icon(Icons.Default.FolderOpen, contentDescription = "파일 선택")
}
}
// 드래그 앤 드롭 영역
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.fillMaxWidth()
.height(80.dp)
.padding(top = 8.dp)
.border(1.dp, Color.LightGray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state -> .onExternalDrag(onDrop = { state ->
val data = state.dragData val data = state.dragData
if (data is DragData.FilesList) { if (data is DragData.FilesList) {
val path = data.readFiles().firstOrNull()?.removePrefix("file:") val path = data.readFiles().firstOrNull()?.removePrefix("file:")
if (path?.endsWith(".gguf") == true) modelPath = path if (path?.endsWith(".gguf") == true) config = config.copy(modelPath = path)
} }
}), }),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray) Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
// 저장 및 접속 버튼
Button( Button(
modifier = Modifier.fillMaxWidth().height(50.dp), modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = !isLoading,
onClick = { onClick = {
isLoading = true
scope.launch { scope.launch {
// 1. 새로운 설정 객체 생성 (순서 주의: isSimulation 다음 modelPath) // isLoading = true
val config = AppConfig( // 1. KisSession.config 업데이트 및 DB 저장
appKey = appKey.trim(), KisSession.config = config
secretKey = secretKey.trim(), DatabaseFactory.saveConfig(config)
accountNo = accountNo.trim(), val authService = KisAuthService()
isSimulation = isSimulation, val tradeService = KisTradeService()
modelPath = modelPath val authSuccess = authService.refreshAllTokens()
) val wsKeySuccess = tradeService.refreshWebsocketKey()
transaction { if (authSuccess && wsKeySuccess) {
ConfigTable.deleteAll() statusMessage = "✅ 인증 성공! LLM 시작 중..."
ConfigTable.insert { onAuthSuccess()
it[ConfigTable.appKey] = config.appKey } else {
it[ConfigTable.secretKey] = config.secretKey statusMessage = "❌ 인증 실패. 키 정보를 확인하세요."
it[ConfigTable.accountNo] = config.accountNo
it[ConfigTable.isSimulation] = config.isSimulation
it[ConfigTable.modelPath] = config.modelPath
}
} }
// isLoading = false
statusMessage = "인증 토큰 발급 시도 중..."
authService.fetchAccessToken(appKey, secretKey, isSimulation)
.onSuccess { response ->
statusMessage = "✅ 인증 성공!"
onAuthSuccess(config, response.access_token)
}.onFailure {
statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}"
}
isLoading = false
} }
} }
) { ) { Text("설정 저장 및 실행") }
if (isLoading) { Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
// [수정된 프로그래스 바] size -> Modifier.size
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text("설정 저장 및 접속 시작")
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(statusMessage, color = if (statusMessage.contains("")) Color.Green else Color.Gray)
} }
} }
} }

View File

@ -34,149 +34,116 @@ import network.KisWebSocketManager
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
@Composable @Composable
fun StockDetailArea( fun StockDetailSection(
config: AppConfig, stockCode: String,
token: String, stockName: String,
code: String, isDomestic: Boolean,
name: String, tradeService: KisTradeService,
wsManager: KisWebSocketManager // 매니저 수신 wsManager: KisWebSocketManager
) { ) {
val currentPrice by wsManager.currentPrice
val priceColor by wsManager.priceChangeColor
val tradeLogs = wsManager.tradeLogs // Manager의 상태를 직접 참조
val tradeService = remember { KisTradeService(config.isSimulation) }
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) } var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var resultMessage by remember { mutableStateOf("") } var resultMessage by remember { mutableStateOf("") }
var isSuccess by remember { mutableStateOf(true) } var isSuccess by remember { mutableStateOf(true) }
LaunchedEffect(code) { // 이전 종목 코드를 기억하기 위한 상태
if (code.isEmpty()) return@LaunchedEffect var previousCode by remember { mutableStateOf("") }
// 종목 변경 시 데이터 로드 및 웹소켓 구독 관리
LaunchedEffect(stockCode) {
if (stockCode.isEmpty()) return@LaunchedEffect
isLoading = true isLoading = true
if (code.isNotEmpty()) {
// 기존 종목 구독 해지 및 새 종목 구독 메시지 전송
// (KisWebSocketManager에 해당 기능을 하는 함수를 만들어서 호출)
wsManager.subscribeStock(code)
}
// 종목 코드 판별 (숫자 6자리면 국내, 아니면 해외로 간주)
val isDomestic = code.all { it.isDigit() } && code.length == 6
val result = if (isDomestic) { // 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
tradeService.fetchChartData(token, config.appKey, config.secretKey, code) if (previousCode.isNotEmpty()) {
.map { it.output2.reversed() } wsManager.unsubscribeStock(previousCode)
} else {
// 해외 주식 처리 (우선 NAS 나스닥 기준으로 호출)
tradeService.fetchOverseasChartData(token, config.appKey, config.secretKey, code)
} }
wsManager.subscribeStock(stockCode)
previousCode = stockCode
result.onSuccess { chartData = it } // 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
.onFailure { println("차트 로드 실패: ${it.message}") } tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력
chartData = data
}
.onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList()
}
isLoading = false isLoading = false
} }
LaunchedEffect(resultMessage) { val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
if (resultMessage.isNotEmpty()) {
delay(3000) LaunchedEffect(latestPrice) {
resultMessage = "" println("latestPrice $latestPrice")
if (chartData.isNotEmpty() && latestPrice != "0") {
// 마지막 캔들 정보 업데이트
val priceDouble = latestPrice.replace(",", "").toDoubleOrNull() ?: return@LaunchedEffect
val lastCandle = chartData.last()
val updatedCandle = lastCandle.copy(
stck_clpr = latestPrice,
stck_hgpr = if (priceDouble > (lastCandle.stck_hgpr.toDoubleOrNull() ?: 0.0)) latestPrice else lastCandle.stck_hgpr,
stck_lwpr = if (priceDouble < (lastCandle.stck_lwpr.toDoubleOrNull() ?: Double.MAX_VALUE)) latestPrice else lastCandle.stck_lwpr
)
chartData = chartData.dropLast(1) + updatedCandle
println("chartData.size $chartData.size")
} }
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// [상단 정보] 국내/해외 구분 배지 추가 // [상단] 종목명 및 상태 메시지
if (resultMessage.isNotEmpty()) { StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess)
Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
) {
Text(
text = resultMessage,
color = Color.White,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
fontSize = 12.sp
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) { // [중앙] 캔들 차트 (Card 내부)
val isDomestic = code.all { it.isDigit() } && code.length == 6
Badge(backgroundColor = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)) {
Text(if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Text(name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
Text(" ($code)", color = Color.Gray)
}
Spacer(modifier = Modifier.height(16.dp))
// [차트 영역] CandleChart 컴포저블 재사용
Card( Card(
modifier = Modifier.fillMaxWidth().height(350.dp), modifier = Modifier.fillMaxWidth().height(300.dp),
backgroundColor = Color(0xFF121212) backgroundColor = Color(0xFF121212)
) { ) {
if (isLoading) { if (isLoading) {
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) } Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
} else if (chartData.isNotEmpty()) {
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
} else { } else {
Box(contentAlignment = Alignment.Center) { Text("데이터가 없습니다.", color = Color.Gray) } CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
} }
} }
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// [중앙 하단] AI 투자 전략
AiAnalysisView( AiAnalysisView(
stockName = name, stockName = stockName,
currentPrice = wsManager.currentPrice.value, currentPrice = wsManager.currentPrice.value,
trades = wsManager.tradeLogs trades = wsManager.tradeLogs
) )
Spacer(modifier = Modifier.height(16.dp))
// 웹 소스 스타일의 주문 박스
// Card(modifier = Modifier.fillMaxWidth(), backgroundColor = Color(0xFFF8F9FA)) {
// Column(modifier = Modifier.padding(16.dp)) {
// Text("주문 설정", fontWeight = FontWeight.Bold)
// // 수량 입력, 매수/매도 버튼 배치 (detail.html 참고)
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Button(onClick = { /* 매수 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFFE03E2D))) {
// Text("매수", color = Color.White)
// }
// Button(onClick = { /* 매도 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFF0E62CF))) {
// Text("매도", color = Color.White)
// }
// }
// }
// }
Column(modifier = Modifier.weight(0.4f)) {
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// 헤더 영역 Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth().background(Color(0xFFEEEEEE)).padding(vertical = 4.dp)) {
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp) // [하단] 실시간 체결 내역 및 주문 섹션
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp) Row(modifier = Modifier.weight(1f)) {
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp) // 실시간 체결 리스트
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp) Column(modifier = Modifier.weight(1f)) {
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
RealTimeTradeList(wsManager.tradeLogs)
} }
// 실시간 리스트 Spacer(modifier = Modifier.width(12.dp))
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tradeLogs) { trade -> // 주문 섹션 (인자 간소화)
TradeLogRow(trade) OrderSection(
Divider(color = Color(0xFFF5F5F5)) stockCode = stockCode,
currentPrice = wsManager.currentPrice.value,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
} }
} )
} }
OrderSection(
config = config,
token = token,
stockCode = code,
currentPrice = currentPrice,
onOrderResult = { msg, success ->
resultMessage = msg
isSuccess = success
}
)
} }
} }

View File

@ -0,0 +1,80 @@
// src/main/kotlin/ui/StockHeader.kt
package ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun StockHeader(
name: String,
code: String,
isDomestic: Boolean,
resultMessage: String,
isSuccess: Boolean
) {
Column(modifier = Modifier.fillMaxWidth()) {
// [1] 알림 메시지 영역 (주문 성공/실패 시 상단에 표시)
if (resultMessage.isNotEmpty()) {
Surface(
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336), // 성공 초록, 실패 빨강
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
) {
Text(
text = resultMessage,
color = Color.White,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
// [2] 종목명 및 국가 배지 영역
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
// 국가 구분 배지
Surface(
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF), // 국내 빨강, 해외 파랑
shape = androidx.compose.foundation.shape.RoundedCornerShape(4.dp)
) {
Text(
text = if (isDomestic) "국내" else "해외",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// 종목 이름
Text(
text = name,
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(6.dp))
// 종목 코드
Text(
text = "($code)",
style = MaterialTheme.typography.body1,
color = Color.Gray
)
}
}
}