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-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-logging:${ktorVersion}")
implementation("ch.qos.logback:logback-classic:1.4.11")
// Database (Exposed & SQLite)
// H2 Database (네이티브 라이브러리 없는 순수 자바 DB)
implementation("com.h2database:h2:2.2.224")

View File

@ -3,21 +3,14 @@ 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.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import kotlinx.coroutines.launch
import network.KisAuthService
import org.jetbrains.exposed.sql.*
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.KisSession
import network.LlamaServerManager
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import ui.DashboardScreen
import ui.SettingsScreen
@ -25,58 +18,58 @@ import ui.SettingsScreen
enum class AppScreen { Settings, Dashboard }
fun main() = application {
// 앱 경로 기준 리소스 위치 설정
// 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치)
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 자동매매") {
var currentScreen by remember { mutableStateOf(AppScreen.Settings) }
// 1. 초기 상태를 null로 두어 로딩 전임을 표시
var savedConfig by remember { mutableStateOf<AppConfig?>(null) }
var token by remember { mutableStateOf("") }
var selectedStockCode by remember { mutableStateOf<String?>(null) }
var isLoaded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// 앱 시작 시 DB에서 마지막 설정 로드
// 1. 앱 시작 시 DB에서 마지막 설정 로드 (KisSession에 주입)
LaunchedEffect(Unit) {
DatabaseFactory.init()
val loaded = transaction {
transaction {
ConfigTable.selectAll().lastOrNull()?.let {
AppConfig(
appKey = it[ConfigTable.appKey],
secretKey = it[ConfigTable.secretKey],
accountNo = it[ConfigTable.accountNo],
KisSession.config = AppConfig(
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]
)
}
}
// 로드된 값이 있으면 업데이트, 없으면 기본 객체 생성
savedConfig = loaded ?: AppConfig()
isLoaded = true
}
// savedConfig가 로드될 때까지 기다림 (깜빡임 방지 및 데이터 보장)
if (savedConfig == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (!isLoaded) {
// 로딩 중 표시
CircularProgressIndicator()
}
} else {
when (currentScreen) {
AppScreen.Settings -> {
SettingsScreen(
initialConfig = savedConfig!!, // !! 사용하여 null 아님을 보장
onAuthSuccess = { config, accessToken ->
savedConfig = config
token = accessToken
onAuthSuccess = {
// 2. 설정 및 인증 완료 시점의 처리
val config = KisSession.config
// LLM 서버 시작 (설정된 모델 경로 사용)
if (config.modelPath.isNotEmpty()) {
LlamaServerManager.startServer(binPath, config.modelPath)
}
// 대시보드로 화면 전환
currentScreen = 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.javatime.datetime
import org.jetbrains.exposed.sql.transactions.transaction
@ -7,13 +8,17 @@ import java.time.LocalDateTime
// 1. 앱 설정 테이블
object ConfigTable : Table("app_config") {
val id = integer("id").autoIncrement()
val appKey = varchar("app_key", 255)
val secretKey = varchar("secret_key", 255)
val accountNo = varchar("account_no", 20)
val isSimulation = bool("is_simulation")
val modelPath = varchar("model_path", 512).default("") // 이 라인이 있어야 합니다.
val realAppKey = varchar("real_app_key", 255).default("")
val realSecretKey = varchar("real_secret_key", 255).default("")
val realAccountNo = varchar("real_account_no", 20).default("")
val vtsAppKey = varchar("vts_app_key", 255).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)
}
// 2. 거래 내역 테이블 (대량 데이터용)
object TradeLogTable : Table("trade_logs") {
val id = long("id").autoIncrement()
@ -56,14 +61,40 @@ object DatabaseFactory {
}
}
// fun fetchRecentLogs(limit: Int = 50): List<TradeLog> {
// return transaction {
// TradeLogTable.selectAll()
// .orderBy(TradeLogTable.timestamp to SortOrder.DESC)
// .limit(limit)
// .map {
// // ResultRow를 객체로 변환
// }
// }
// }
fun findConfigByAccount(accountNo: String): AppConfig? {
return transaction {
ConfigTable.select {
(ConfigTable.realAccountNo eq accountNo) or (ConfigTable.vtsAccountNo eq accountNo)
}.lastOrNull()?.let {
AppConfig(
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
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
data class AppConfig(
val appKey: String = "",
val secretKey: String = "",
val accountNo: String = "",
// [DB 저장 데이터]
// 실전 3종
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 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_token_expired: 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_lwpr: String, // 저가
val stck_clpr: String, // 종가
val acml_vol: String // 누적 거래량
val acml_vol: String ="" // 누적 거래량
)
@Serializable
data class OverseasCandleData(

View File

@ -1,5 +1,6 @@
package model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@ -30,28 +31,53 @@ data class BalanceSummary(
)
@Serializable
data class RankingResponse(
var rt_cd : String,
var msg1 : String,
var msg_cd : String,
val output1: List<RankingStock> = emptyList(),
val output: List<RankingStock> = emptyList()
)
enum class RankingType(val code: String, val title: String) {
RISE("0", "상승"),
FALL("1", "하락"),
VOLUME("2", "거래량"),
AMOUNT("3", "금액"),
OVERTIME("4", "시간외"), // 웹 소스의 시간외 상승 TR 연동
SHORT_HOT("5", "단기추천") // 단기 과열 및 추천 종목
) {
val list = output + output1 + emptyList()
}
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
data class RankingStock(
val hts_kor_isnm: String = "", // 종목명
val hts_kor_alph_nm: String = "", // 종목명
val mkrtc_objt_iscd: String = "", // 종목코드
val mksc_shrn_iscd: String = "", // 종목코드
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
data class OverseasRankingResponse(
val rt_cd: String = "",
val msg1: String = "",
val output: List<OverseasRankingStock> = emptyList()
)
@ -71,3 +97,22 @@ data class OverseasRankingStock(
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.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import model.KisSession
import model.TokenRequest
import model.TokenResponse
import java.time.LocalDateTime
class KisAuthService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
}
// 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능)
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
level = LogLevel.BODY
logger = Logger.DEFAULT
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
}
}
private fun getBaseUrl(isSimulation: Boolean): String {
return if (isSimulation) {
"https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨
private fun getBaseUrl(isSimulation: Boolean) =
if (isSimulation) "https://openapivts.koreainvestment.com:29443"
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 {
"https://openapi.koreainvestment.com:9443"
false
}
}
suspend fun fetchAccessToken(
appKey: String,
secretKey: String,
isSimulation: Boolean
): Result<TokenResponse> {
private suspend fun fetchAccessToken(appKey: String, secretKey: String, isSim: Boolean): Result<TokenResponse> {
return try {
val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP"
val response = client.post(url) {
// 헤더 설정 (매우 중요)
val response = client.post("${getBaseUrl(isSim)}/oauth2/tokenP") {
contentType(ContentType.Application.Json)
// 요청 바디 (TokenRequest 객체 전달)
setBody(TokenRequest(
"client_credentials",
appKey,
secretKey
))
}
if (response.status == HttpStatusCode.OK) {
Result.success(response.body())
} else {
val errorBody = response.bodyAsText()
println("HTTP ${response.status}: $errorBody")
Result.failure(Exception("HTTP ${response.status}: $errorBody"))
setBody(TokenRequest("client_credentials", appKey, secretKey))
}
if (response.status == HttpStatusCode.OK) Result.success(response.body())
else Result.failure(Exception("인증 실패: ${response.status}"))
} catch (e: Exception) {
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.Logging
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import model.AppConfig
import model.CandleData
import model.ChartResponse
import model.OverseasChartResponse
import model.OverseasRankingResponse
import model.RankingResponse
import model.RankingStock
import model.RankingType
import model.StockBalanceResponse
class KisTradeService(private val isSimulation: Boolean) {
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) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
}
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY
level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경
}
}
suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/pdy-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/pdy-rank"
println("📡 [REQ] 국내 전일 등락 조회: $url")
val response = client.get(url) {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "HHPST01710000")
header("custtype", "P")
header("Content-Type", "application/json; charset=utf-8") // 헤더 명시
parameter("fid_cond_mrkt_div_code", "J")
parameter("fid_cond_scr_div_code", "20171")
parameter("fid_input_iscd", "0000")
parameter("fid_rank_sort_cls_code", "0")
parameter("fid_input_cntstr_value", "")
parameter("fid_prc_cls_code", "1")
}
if (response.status != HttpStatusCode.OK) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}: $errorBody"))
}
val body = response.body<RankingResponse>()
Result.success(body.output.take(20))
} catch (e: Exception) {
println("❌ [ERR] 국내 전일 등락 실패: ${e.message}")
Result.failure(e)
}
}
private val prodUrl = "https://openapi.koreainvestment.com:9443"
private val vtsUrl = "https://openapivts.koreainvestment.com:29443"
/**
* [2] 국내 실시간 마켓 랭킹 (장중용)
* TR ID: FHPST01700000
* [1] 통합 잔고 조회 (국내 + 해외 합산)
*/
suspend fun fetchMarketRanking(
token: String,
config: AppConfig,
type: RankingType,
isDomestic: Boolean
): Result<List<RankingStock>> {
if (!isDomestic) return Result.failure(Exception("Domestic only"))
suspend fun fetchIntegratedBalance(): Result<UnifiedBalance> = coroutineScope {
val config = KisSession.config
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank"
println("📡 [REQ] 국내 실시간 랭킹 조회: $url")
// 국내와 해외 잔고를 비동기로 동시 호출
val domesticJob = async { fetchDomesticRawBalance() }
val overseasJob = async { fetchOverseasRawBalance() }
val response = client.get(url) {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "FHPST01700000")
header("custtype", "P")
header("Content-Type", "application/json; charset=utf-8")
try {
val domRes = domesticJob.await().getOrNull()
val ovsRes = overseasJob.await().getOrNull()
parameter("fid_cond_mrkt_div_code", "J")
parameter("fid_cond_scr_div_code", "20170")
parameter("fid_input_iscd", "0000")
parameter("fid_div_cls_code", "0")
parameter("fid_rank_sort_cls_code", type.code)
parameter("fid_etc_cls_code", "0")
val combinedHoldings = mutableListOf<UnifiedStockHolding>()
// 국내 종목 매핑
domRes?.output1?.forEach {
combinedHoldings.add(UnifiedStockHolding(
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) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}"))
// 해외 종목 매핑 (해외 API 응답 모델 구조에 따라 필드 매핑)
ovsRes?.output1?.forEach {
combinedHoldings.add(UnifiedStockHolding(
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>()
Result.success(body.output.take(20))
} catch (e: Exception) {
println("❌ [ERR] 실시간 랭킹 실패: ${e.message}")
Result.failure(e)
}
}
private val prodBaseUrl = "https://openapi.koreainvestment.com:9443"
// 해외 실시간/전일 등락 상위
suspend fun fetchOverseasRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
return try {
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/rank-fluctuation") {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "HHDFS76240000")
parameter("EXCD", "NAS") // 나스닥 기준
parameter("GUBN", "0") // 상승률순
}
val body = response.body<OverseasRankingResponse>()
Result.success(body.output.map { it.toRankingStock() }.take(20))
val totalAmt = (domRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L) +
(ovsRes?.output2?.firstOrNull()?.tot_evlu_amt?.toLongOrNull() ?: 0L)
Result.success(UnifiedBalance(
totalAsset = String.format("%,d", totalAmt),
totalProfitRate = domRes?.output2?.firstOrNull()?.evlu_pfls_rt ?: "0.0",
holdings = combinedHoldings
))
} catch (e: Exception) { Result.failure(e) }
}
private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443"
else "https://openapi.koreainvestment.com:9443"
suspend fun fetchBalance(
token: String,
appKey: String,
appSecret: String,
accountNo: String
): Result<StockBalanceResponse> {
return try {
val cleanAccount = accountNo.filter { it.isDigit() }
if (cleanAccount.length != 10) {
return Result.failure(Exception("계좌번호 10자리를 입력해주세요."))
/**
* [통합 순위 조회] 국내/해외 분기 처리
*/
suspend fun fetchMarketRanking(type: RankingType, isDomestic: Boolean): Result<List<RankingStock>> {
return if (isDomestic) {
fetchDomesticRanking(type)
} else {
fetchOverseasRanking(type)
}
}
val cano = cleanAccount.take(8)
val acntCd = cleanAccount.takeLast(2)
// 웹 소스(KisApiService.kt) 54행 로직 적용
// 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님)
val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R"
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
/**
* [국내 주식 순위] 명세서 기반 파라미터 최적화
*/
private suspend fun fetchDomesticRanking(type: RankingType): Result<List<RankingStock>> {
val config = KisSession.config
return try {
val response = client.get("$prodUrl${type.path}") {
header("authorization", "Bearer ${config.marketToken}")
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 body = response.body<RankingResponse>()
if (body.rt_cd == "0") Result.success(body.list) else Result.failure(Exception(body.msg1))
} catch (e: Exception) { Result.failure(e) }
}
/**
* [해외 주식 순위] 모델 매핑 오류 수정
*/
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"
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")
// 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntCd)
parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN
parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN
parameter("EXCD", "NAS") // 기본 나스닥
val gubn = when (type) {
RankingType.RISE -> "0"
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("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
@ -178,143 +344,12 @@ class KisTradeService(private val isSimulation: Boolean) {
parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "")
}
if (response.status == HttpStatusCode.OK) {
val body = response.body<StockBalanceResponse>()
if (body.rt_cd == "0") {
Result.success(body)
} else {
Result.failure(Exception("API 에러: ${body.msg1} (코드:${body.rt_cd})"))
}
} else {
Result.failure(Exception("HTTP 오류: ${response.status}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String
): Result<ChartResponse> {
return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", "FHKST03010100") // 국내주식 기간별 시세 TR ID
header("custtype", "P")
parameter("FID_COND_SCR_DIV_CODE", "16.4")
parameter("FID_INPUT_ISCD", stockCode)
parameter("FID_INPUT_DATE_1", "20240101") // 시작일 (예시)
parameter("FID_INPUT_DATE_2", "20260110") // 종료일
parameter("FID_PERIOD_DIV_CODE", "D") // 일봉
parameter("FID_ORG_ADJ_PRC", "0") // 수정주가 반영
}
Result.success(response.body())
} catch (e: Exception) {
Result.failure(e)
}
} catch (e: Exception) { Result.failure(e) }
}
suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? {
return try {
val response = client.post("$baseUrl/oauth2/Approval") {
header("Content-Type", "application/json")
setBody(mapOf("grant_type" to "client_credentials", "appkey" to appKey, "secretkey" to appSecret))
}
// 응답에서 approval_key만 추출 (실제 모델 정의 필요)
val json = response.body<Map<String, String>>()
json["approval_key"]
} catch (e: Exception) {
null
}
}
suspend fun fetchOverseasChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String,
excd: String = "NAS" // 기본 나스닥
): Result<List<CandleData>> {
return try {
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/inquire-daily-chartprice") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", "HHDFS76240000") // 해외 주식 기간별 시세 TR ID
header("custtype", "P")
parameter("EXCD", excd)
parameter("SYMB", stockCode)
parameter("GUBN", "0") // 0: 일봉, 1: 주봉, 2: 월봉
parameter("BYMD", "") // 공백 시 현재일 기준
parameter("MODP", "Y") // 수정주가 반영
}
val body = response.body<OverseasChartResponse>()
// 해외 데이터를 공통 CandleData 형식으로 변환하여 차트 컴포저블 재사용
val converted = body.output2.map {
CandleData(
stck_bsop_date = it.xy_date,
stck_oprc = it.open,
stck_hgpr = it.high,
stck_lwpr = it.low,
stck_clpr = it.last,
acml_vol = it.t_vol
)
}.reversed()
Result.success(converted)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun postOrder(
token: String,
config: AppConfig,
stockCode: String,
qty: String,
price: String, // "0"이면 시장가
isBuy: Boolean
): Result<String> {
return try {
val cleanAccount = config.accountNo.filter { it.isDigit() }
val trId = if (config.isSimulation) {
if (isBuy) "VTRP0001U" else "VTRP0002U" // 모의: 매수/매도
} else {
if (isBuy) "TTTC0802U" else "TTTC0801U" // 실전: 매수/매도
}
val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-cash") {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", trId)
header("Content-Type", "application/json")
setBody(mapOf(
"CANO" to cleanAccount.take(8),
"ACNT_PRDT_CD" to cleanAccount.takeLast(2),
"PDNO" to stockCode,
"ORD_DVSN" to if (price == "0") "01" else "00", // 01:시장가, 00:지정가
"ORD_QTY" to qty,
"ORD_UNPR" to price
))
}
val body = response.body<Map<String, Any>>()
if (body["rt_cd"] == "0") {
Result.success("주문 성공: ${body["msg1"]}")
} else {
Result.failure(Exception("${body["msg1"]}"))
}
} catch (e: Exception) {
Result.failure(e)
}
private suspend fun fetchOverseasRawBalance(): Result<StockBalanceResponse> {
// 해외 잔고 조회 API 명세에 맞춰 구현 (국내와 유사하나 TR ID 및 파라미터 다름)
return Result.failure(Exception("Not Implemented"))
}
}

View File

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

View File

@ -10,7 +10,7 @@ object LlamaServerManager {
private val scope = CoroutineScope(Dispatchers.IO + Job())
fun startServer(binPath: String, modelPath: String) {
if (process != null) return // 이미 실행 중이면 무시
if (process != null || modelPath.isNullOrBlank()) return // 이미 실행 중이면 무시
val command = listOf(
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.sp
import kotlinx.coroutines.launch
import model.KisSession
import model.RealTimeTrade
import network.AiService
@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 isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// 1. 모델 경로 유효성 체크
val isModelConfigured = remember {
val path = util.AppConfigManager.modelPath
// KisSession의 전역 설정을 참조
val isModelConfigured = remember(KisSession.config.modelPath) {
val path = KisSession.config.modelPath
path.isNotEmpty() && java.io.File(path).exists()
}
Card(
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)) {
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
)
Spacer(Modifier.weight(1f))
// 2. 경로가 정상일 때만 버튼 활성화
Button(
onClick = {
scope.launch {
isLoading = true
aiOpinion = "Gemma가 데이터를 읽고 있습니다..."
aiOpinion = "데이터 분석 중..."
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
isLoading = false
}
},
enabled = isModelConfigured && !isLoading, // 유효성 체크 반영
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.White,
disabledBackgroundColor = Color(0xFFE0E0E0)
)
enabled = isModelConfigured && !isLoading
) {
Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp)
}
}
if (!isModelConfigured) {
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 // 캔들 사이 간격
// 1. 가격 범위 계산 (스케일링용)
val maxPrice = data.maxOf { it.stck_hgpr.toDouble() }
val minPrice = data.minOf { it.stck_lwpr.toDouble() }
val maxPrice = data.maxOf { it.stck_hgpr.toDoubleOrNull() ?: 0.0 }
val minPrice = data.minOf { it.stck_lwpr.toDoubleOrNull() ?: 0.0 }
val priceRange = maxPrice - minPrice
// priceRange가 0일 경우(데이터가 모두 같을 때) 분모가 0이 되는 것 방지
fun getY(price: Double): Float {
if (priceRange == 0.0) return height / 2f
return (height - ((price - minPrice) / priceRange * height)).toFloat()
}
// 루프 내부에서도 동일하게 적용
data.forEachIndexed { index, candle ->
val open = candle.stck_oprc.toDouble()
val close = candle.stck_clpr.toDouble()
val high = candle.stck_hgpr.toDouble()
val low = candle.stck_lwpr.toDouble()
val open = candle.stck_oprc.toDoubleOrNull() ?: 0.0
val close = candle.stck_clpr.toDoubleOrNull() ?: 0.0
val high = candle.stck_hgpr.toDoubleOrNull() ?: 0.0
val low = candle.stck_lwpr.toDoubleOrNull() ?: 0.0
val isRising = close >= open
val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)

View File

@ -1,302 +1,78 @@
// src/main/kotlin/ui/DashboardScreen.kt
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.Modifier
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.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.RankingType
import model.StockHolding
import model.KisSession
import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil
@Composable
fun DashboardScreen(config: AppConfig, token: String) {
val wsManager = remember { KisWebSocketManager(config.isSimulation) }
val tradeService = remember { KisTradeService(config.isSimulation) }
fun DashboardScreen() {
val tradeService = remember { KisTradeService() }
val wsManager = remember { KisWebSocketManager() }
// 전역 상태: 현재 선택된 종목
// 전역 상태: 현재 선택된 종목 정보
var selectedStockCode 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) {
val approvalKey = tradeService.fetchApprovalKey(config.appKey, config.secretKey)
approvalKey?.let { wsManager.connect(it) }
tradeService.fetchBalance(token, config.appKey, config.secretKey, config.accountNo)
.onSuccess {
holdings = it.output1
summary = it.output2.firstOrNull()
}
wsManager.connect()
}
// 메인 3분할 레이아웃
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 나의 자산 및 잔고
// [좌측 25%] 내 자산 및 통합 잔고
Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) {
Text("나의 잔고", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
BalanceSummaryCard(summary)
Spacer(modifier = Modifier.height(8.dp))
MyStockList(holdings) { code, name ->
BalanceSection(tradeService) { code, name, isDom ->
selectedStockCode = code
selectedStockName = name
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
}
}
VerticalDivider()
// [중앙 45%] 실시간 차트 및 주문 (가장 중요)
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) {
// [중앙 45%] 실시간 정보 및 주문
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White)) {
if (selectedStockCode.isNotEmpty()) {
StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager)
StockDetailSection(
stockCode = selectedStockCode,
stockName = selectedStockName,
isDomestic = isDomestic,
tradeService = tradeService,
wsManager = wsManager
)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray)
Text("분석할 종목을 선택하세요", color = Color.Gray)
}
}
}
VerticalDivider()
// [우측 30%] 시장 추천 리스트 (탭 방식)
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
Text("시장 추천 TOP 20", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
RecommendationTabs(config, token) { code, name ->
MarketSection(tradeService) { code, name, isDom ->
selectedStockCode = code
selectedStockName = name
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
}
}
}
}
@Composable
fun StockItemRow(stock: StockHolding) {
Row(
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)
)
}
}
}
}
fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}

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

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
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
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.Modifier
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.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.BalanceSummary
import model.RankingStock
import model.RankingType
import model.StockHolding
import network.KisTradeService
import util.MarketUtil
@Composable
fun RecommendationTabs(
config: AppConfig,
token: String,
onSelect: (String, String) -> Unit
) {
var isDomestic by remember { mutableStateOf(true) }
var selectedType by remember { mutableStateOf(RankingType.RISE) }
var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
val tradeService = remember { KisTradeService(config.isSimulation) }
val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
// 데이터 로드 로직
LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
errorMessage = null // 로딩 시작 시 에러 초기화
if (isDomestic) {
if (isKoreaOpen) {
tradeService.fetchMarketRanking(token, config, selectedType, true)
.onSuccess { rankingList = it.take(20) }
.onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
} else {
tradeService.fetchDomesticPreviousDayRanking(token, config)
.onSuccess { rankingList = it }
.onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
}
} else {
tradeService.fetchOverseasRanking(token, config)
.onSuccess { rankingList = it }
.onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
}
}
Column(modifier = Modifier.fillMaxSize()) {
// [1] 국내/해외 전환 버튼 (항상 노출)
Row(Modifier.fillMaxWidth().padding(8.dp)) {
MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
Spacer(Modifier.width(8.dp))
MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
}
// [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
ScrollableTabRow(
selectedTabIndex = selectedType.ordinal,
edgePadding = 8.dp,
backgroundColor = Color.White,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
)
}
) {
RankingType.values().forEach { type ->
Tab(
selected = selectedType == type,
onClick = { selectedType = type },
text = { Text(type.title, fontSize = 12.sp) }
)
}
}
// [3] 장외 시간 안내 바
if (isDomestic && !isKoreaOpen) {
Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
Text(
"현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
fontSize = 11.sp, modifier = Modifier.padding(8.dp)
)
}
}
// [4] 추천 리스트 영역
Box(modifier = Modifier.weight(1f)) {
if (errorMessage != null) {
// 에러 발생 시 안내
Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
Text(errorMessage!!, color = Color.Gray)
Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
}
} else if (rankingList.isEmpty()) {
// 로딩 중
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
// [성공] 리스트 노출
LazyColumn {
itemsIndexed(rankingList) { index, stock ->
RankingItemRow(index, stock, isDomestic, selectedType) {
onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
}
}
}
}
}
}
}
@Composable
fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
colors = ButtonDefaults.outlinedButtonColors(
backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
),
modifier = Modifier.height(36.dp),
border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
) {
Text(
text = title,
color = if (isSelected) activeColor else Color.Gray,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
//package ui
//
//
//import androidx.compose.foundation.BorderStroke
//import androidx.compose.foundation.background
//import androidx.compose.foundation.clickable
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
//import androidx.compose.foundation.lazy.itemsIndexed
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material.*
//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
//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.Modifier
//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.sp
//import kotlinx.coroutines.launch
//import model.AppConfig
//import model.BalanceSummary
//import model.RankingStock
//import model.RankingType
//import model.StockHolding
//import network.KisTradeService
//import util.MarketUtil
//
//@Composable
//fun RecommendationTabs(
// config: AppConfig,
// token: String,
// onSelect: (String, String) -> Unit
//) {
// var isDomestic by remember { mutableStateOf(true) }
// var selectedType by remember { mutableStateOf(RankingType.RISE) }
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
//
// val tradeService = remember { KisTradeService(config.isSimulation) }
// val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
// var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
//
// // 데이터 로드 로직
// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
// errorMessage = null // 로딩 시작 시 에러 초기화
// if (isDomestic) {
// if (isKoreaOpen) {
// tradeService.fetchMarketRanking(token, config, selectedType, true)
// .onSuccess { rankingList = it.take(20) }
// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
// } else {
// tradeService.fetchDomesticPreviousDayRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
// }
// } else {
// tradeService.fetchOverseasRanking(token, config)
// .onSuccess { rankingList = it }
// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
// }
// }
//
// Column(modifier = Modifier.fillMaxSize()) {
// // [1] 국내/해외 전환 버튼 (항상 노출)
// Row(Modifier.fillMaxWidth().padding(8.dp)) {
// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
// Spacer(Modifier.width(8.dp))
// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
// }
//
// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
// ScrollableTabRow(
// selectedTabIndex = selectedType.ordinal,
// edgePadding = 8.dp,
// backgroundColor = Color.White,
// indicator = { tabPositions ->
// TabRowDefaults.Indicator(
// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
// )
// }
// ) {
// RankingType.values().forEach { type ->
// Tab(
// selected = selectedType == type,
// onClick = { selectedType = type },
// text = { Text(type.title, fontSize = 12.sp) }
// )
// }
// }
//
// // [3] 장외 시간 안내 바
// if (isDomestic && !isKoreaOpen) {
// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
// Text(
// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
// fontSize = 11.sp, modifier = Modifier.padding(8.dp)
// )
// }
// }
//
// // [4] 추천 리스트 영역
// Box(modifier = Modifier.weight(1f)) {
// if (errorMessage != null) {
// // 에러 발생 시 안내
// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
// Text(errorMessage!!, color = Color.Gray)
// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
// }
// } else if (rankingList.isEmpty()) {
// // 로딩 중
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// CircularProgressIndicator()
// }
// } else {
// // [성공] 리스트 노출
// LazyColumn {
// itemsIndexed(rankingList) { index, stock ->
// RankingItemRow(index, stock, isDomestic, selectedType) {
// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
// }
// }
// }
// }
// }
// }
//}
//
//@Composable
//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
// OutlinedButton(
// onClick = onClick,
// colors = ButtonDefaults.outlinedButtonColors(
// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
// ),
// modifier = Modifier.height(36.dp),
// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
// ) {
// Text(
// text = title,
// color = if (isSelected) activeColor else Color.Gray,
// fontSize = 12.sp,
// 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.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.DragData
@ -14,151 +12,113 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.onExternalDrag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.AppConfig
import model.KisSession
import network.KisAuthService
import network.KisTradeService
import org.jetbrains.exposed.sql.deleteAll
import org.jetbrains.exposed.sql.insert
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)
@Composable
fun SettingsScreen(
initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요
onAuthSuccess: (AppConfig, String) -> Unit
) {
fun SettingsScreen(onAuthSuccess: () -> Unit) {
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) }
var secretKey by remember { mutableStateOf(initialConfig.secretKey) }
var accountNo by remember { mutableStateOf(initialConfig.accountNo) }
var isSimulation by remember { mutableStateOf(initialConfig.isSimulation) }
var modelPath by remember { mutableStateOf(initialConfig.modelPath ?: "") } // AI 모델 경로
var statusMessage by remember { mutableStateOf("설정 정보를 입력하세요.") }
var isLoading by remember { mutableStateOf(false) }
// 계좌번호 입력 시 데이터 자동 로드 함수
fun checkAndLoadConfig(accountNo: String, isReal: Boolean) {
val loaded = DatabaseFactory.findConfigByAccount(accountNo)
if (loaded != null) {
config = loaded
statusMessage = "✅ 기존 데이터를 불러왔습니다."
}
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
item {
Text("API 및 계좌 설정", 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())
Text("거래 방식 선택", style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it })
Text("모의투자 서버 사용")
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false) })
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))
// --- 추가된 AI 모델 설정 섹션 ---
Text("AI 모델 설정 (Gemma-2-9b)", style = MaterialTheme.typography.h6)
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 = "파일 선택")
}
}
// 드래그 앤 드롭 영역
// AI 모델 경로 및 드래그 앤 드롭
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.padding(top = 8.dp)
.border(1.dp, Color.LightGray, RoundedCornerShape(8.dp))
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
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
) {
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(
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = !isLoading,
onClick = {
isLoading = true
scope.launch {
// 1. 새로운 설정 객체 생성 (순서 주의: isSimulation 다음 modelPath)
val config = AppConfig(
appKey = appKey.trim(),
secretKey = secretKey.trim(),
accountNo = accountNo.trim(),
isSimulation = isSimulation,
modelPath = modelPath
)
// isLoading = true
// 1. KisSession.config 업데이트 및 DB 저장
KisSession.config = config
DatabaseFactory.saveConfig(config)
val authService = KisAuthService()
val tradeService = KisTradeService()
val authSuccess = authService.refreshAllTokens()
val wsKeySuccess = tradeService.refreshWebsocketKey()
transaction {
ConfigTable.deleteAll()
ConfigTable.insert {
it[ConfigTable.appKey] = config.appKey
it[ConfigTable.secretKey] = config.secretKey
it[ConfigTable.accountNo] = config.accountNo
it[ConfigTable.isSimulation] = config.isSimulation
it[ConfigTable.modelPath] = config.modelPath
}
}
statusMessage = "인증 토큰 발급 시도 중..."
authService.fetchAccessToken(appKey, secretKey, isSimulation)
.onSuccess { response ->
statusMessage = "✅ 인증 성공!"
onAuthSuccess(config, response.access_token)
}.onFailure {
statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}"
}
isLoading = false
}
}
) {
if (isLoading) {
// [수정된 프로그래스 바] size -> Modifier.size
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
if (authSuccess && wsKeySuccess) {
statusMessage = "✅ 인증 성공! LLM 시작 중..."
onAuthSuccess()
} else {
Text("설정 저장 및 접속 시작")
statusMessage = "❌ 인증 실패. 키 정보를 확인하세요."
}
// isLoading = false
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(statusMessage, color = if (statusMessage.contains("")) Color.Green else Color.Gray)
) { Text("설정 저장 및 실행") }
Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
}
}
}

View File

@ -34,149 +34,116 @@ import network.KisWebSocketManager
import kotlin.collections.isNotEmpty
@Composable
fun StockDetailArea(
config: AppConfig,
token: String,
code: String,
name: String,
wsManager: KisWebSocketManager // 매니저 수신
fun StockDetailSection(
stockCode: String,
stockName: String,
isDomestic: Boolean,
tradeService: KisTradeService,
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 isLoading by remember { mutableStateOf(false) }
var resultMessage by remember { mutableStateOf("") }
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
if (code.isNotEmpty()) {
// 기존 종목 구독 해지 및 새 종목 구독 메시지 전송
// (KisWebSocketManager에 해당 기능을 하는 함수를 만들어서 호출)
wsManager.subscribeStock(code)
}
// 종목 코드 판별 (숫자 6자리면 국내, 아니면 해외로 간주)
val isDomestic = code.all { it.isDigit() } && code.length == 6
val result = if (isDomestic) {
tradeService.fetchChartData(token, config.appKey, config.secretKey, code)
.map { it.output2.reversed() }
} else {
// 해외 주식 처리 (우선 NAS 나스닥 기준으로 호출)
tradeService.fetchOverseasChartData(token, config.appKey, config.secretKey, code)
// 1. 웹소켓 구독 관리: 이전 종목 해제 -> 새 종목 구독
if (previousCode.isNotEmpty()) {
wsManager.unsubscribeStock(previousCode)
}
wsManager.subscribeStock(stockCode)
previousCode = stockCode
result.onSuccess { chartData = it }
.onFailure { println("차트 로드 실패: ${it.message}") }
// 2. 차트 데이터 로드 (KisSession 기반으로 파라미터 간소화)
tradeService.fetchChartData(stockCode, isDomestic)
.onSuccess { data ->
println("✅ 차트 데이터 로드 성공: ${data.size}") // ${} 사용하여 정확히 출력
chartData = data
}
.onFailure { error ->
println("❌ 차트 데이터 로드 실패: ${error.localizedMessage}")
chartData = emptyList()
}
isLoading = false
}
LaunchedEffect(resultMessage) {
if (resultMessage.isNotEmpty()) {
delay(3000)
resultMessage = ""
}
}
val latestPrice by wsManager.currentPrice // 웹소켓에서 업데이트되는 현재가
Column(modifier = Modifier.fillMaxSize()) {
// [상단 정보] 국내/해외 구분 배지 추가
if (resultMessage.isNotEmpty()) {
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
LaunchedEffect(latestPrice) {
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")
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
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)
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// [상단] 종목명 및 상태 메시지
StockHeader(stockName, stockCode, isDomestic, resultMessage, isSuccess)
Spacer(modifier = Modifier.height(16.dp))
// [차트 영역] CandleChart 컴포저블 재사용
// [중앙] 캔들 차트 (Card 내부)
Card(
modifier = Modifier.fillMaxWidth().height(350.dp),
modifier = Modifier.fillMaxWidth().height(300.dp),
backgroundColor = Color(0xFF121212)
) {
if (isLoading) {
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
} else if (chartData.isNotEmpty()) {
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
} 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(
stockName = name,
stockName = stockName,
currentPrice = wsManager.currentPrice.value,
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)) {
Spacer(modifier = Modifier.height(12.dp))
// [하단] 실시간 체결 내역 및 주문 섹션
Row(modifier = Modifier.weight(1f)) {
// 실시간 체결 리스트
Column(modifier = Modifier.weight(1f)) {
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
// 헤더 영역
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)
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
RealTimeTradeList(wsManager.tradeLogs)
}
// 실시간 리스트
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tradeLogs) { trade ->
TradeLogRow(trade)
Divider(color = Color(0xFFF5F5F5))
}
}
}
Spacer(modifier = Modifier.width(12.dp))
// 주문 섹션 (인자 간소화)
OrderSection(
config = config,
token = token,
stockCode = code,
currentPrice = currentPrice,
stockCode = stockCode,
currentPrice = wsManager.currentPrice.value,
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
)
}
}
}