diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1dff0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4e1ace4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,62 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") version "2.0.0" + id("org.jetbrains.compose") version "1.6.11" + kotlin("plugin.compose") version "2.0.0" + kotlin("plugin.serialization") version "2.0.0" +} + +group = "com.autotrade" +version = "1.0.0" + +repositories { + mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material) + implementation(compose.materialIconsExtended) + + // Ktor (Network) + val ktorVersion = "2.3.11" + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation("io.ktor:ktor-client-logging:${ktorVersion}") + // Database (Exposed & SQLite) + // H2 Database (네이티브 라이브러리 없는 순수 자바 DB) + implementation("com.h2database:h2:2.2.224") + + implementation("io.ktor:ktor-client-websockets:${ktorVersion}") + + // SQL 프레임워크 (Exposed) + val exposedVersion = "0.50.1" + implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion") // 날짜 처리를 위해 필수 + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") +} + +compose.desktop { + application { + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg) + packageName = "AutoTradeAI" + macOS { + bundleID = "com.autotrade.ai" + } + } + } +} + +kotlin { + jvmToolchain(17) // 이 줄이 JDK 17 사용을 강제합니다. +} \ No newline at end of file diff --git a/db/autotrade_db.mv.db b/db/autotrade_db.mv.db new file mode 100644 index 0000000..edee5ae Binary files /dev/null and b/db/autotrade_db.mv.db differ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3d6fdee --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +rootProject.name = "AutoTradeAI" \ No newline at end of file diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt new file mode 100644 index 0000000..18a8726 --- /dev/null +++ b/src/main/kotlin/Main.kt @@ -0,0 +1,84 @@ +import androidx.compose.foundation.layout.* +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 network.LlamaServerManager +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import ui.DashboardScreen +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(null) } + var token by remember { mutableStateOf("") } + var selectedStockCode by remember { mutableStateOf(null) } + + // 앱 시작 시 DB에서 마지막 설정 로드 + LaunchedEffect(Unit) { + DatabaseFactory.init() + val loaded = transaction { + ConfigTable.selectAll().lastOrNull()?.let { + AppConfig( + appKey = it[ConfigTable.appKey], + secretKey = it[ConfigTable.secretKey], + accountNo = it[ConfigTable.accountNo], + isSimulation = it[ConfigTable.isSimulation], + modelPath = it[ConfigTable.modelPath] + ) + } + } + // 로드된 값이 있으면 업데이트, 없으면 기본 객체 생성 + savedConfig = loaded ?: AppConfig() + } + + // savedConfig가 로드될 때까지 기다림 (깜빡임 방지 및 데이터 보장) + if (savedConfig == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (currentScreen) { + AppScreen.Settings -> { + SettingsScreen( + initialConfig = savedConfig!!, // !! 사용하여 null 아님을 보장 + onAuthSuccess = { config, accessToken -> + savedConfig = config + token = accessToken + currentScreen = AppScreen.Dashboard + } + ) + } + AppScreen.Dashboard -> { + DashboardScreen(config = savedConfig!!, token = token) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt new file mode 100644 index 0000000..f35942a --- /dev/null +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -0,0 +1,69 @@ +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.javatime.datetime +import org.jetbrains.exposed.sql.transactions.transaction +import java.io.File +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("") // 이 라인이 있어야 합니다. + override val primaryKey = PrimaryKey(id) +} +// 2. 거래 내역 테이블 (대량 데이터용) +object TradeLogTable : Table("trade_logs") { + val id = long("id").autoIncrement() + val stockCode = varchar("stock_code", 20) // 종목코드 + val stockName = varchar("stock_name", 50) // 종목명 + val tradeType = varchar("trade_type", 10) // 매수/매도 + val price = double("price") // 체결가 + val quantity = integer("quantity") // 수량 + val timestamp = datetime("timestamp") // 거래 시간 + val logMessage = text("log_message") // Ollama의 판단 근거 등 상세 정보 + override val primaryKey = PrimaryKey(id) +} + +object DatabaseFactory { + fun init() { + + val dbPath =File("db/autotrade_db").absolutePath + // 드라이버를 org.h2.Driver로 설정 + Database.connect( + "jdbc:h2:$dbPath;DB_CLOSE_DELAY=-1;", + driver = "org.h2.Driver" + ) + + transaction { + SchemaUtils.create(ConfigTable, TradeLogTable) + } + } + + fun saveTradeLog(code: String, name: String, type: String, price: Double, qty: Int, msg: String) { + transaction { + TradeLogTable.insert { + it[stockCode] = code + it[stockName] = name + it[tradeType] = type + it[TradeLogTable.price] = price + it[quantity] = qty + it[timestamp] = LocalDateTime.now() + it[logMessage] = msg + } + } + } + +// fun fetchRecentLogs(limit: Int = 50): List { +// return transaction { +// TradeLogTable.selectAll() +// .orderBy(TradeLogTable.timestamp to SortOrder.DESC) +// .limit(limit) +// .map { +// // ResultRow를 객체로 변환 +// } +// } +// } +} \ No newline at end of file diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt new file mode 100644 index 0000000..cb8bb6e --- /dev/null +++ b/src/main/kotlin/model/AppConfig.kt @@ -0,0 +1,11 @@ +package model + +import kotlinx.serialization.Serializable + +data class AppConfig( + val appKey: String = "", + val secretKey: String = "", + val accountNo: String = "", + val isSimulation: Boolean = true, + val modelPath: String = "" // 추가된 필드 +) \ No newline at end of file diff --git a/src/main/kotlin/model/AuthModels.kt b/src/main/kotlin/model/AuthModels.kt new file mode 100644 index 0000000..f4619cf --- /dev/null +++ b/src/main/kotlin/model/AuthModels.kt @@ -0,0 +1,21 @@ +package model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +@Serializable +data class TokenRequest( + val grant_type: String = "client_credentials", + val appkey: String, + @SerialName("appsecret") // 중요: KIS 공식 문서와 달리 웹 소스에서 성공한 키 명칭 적용 + val appSecret: String + +) + +@Serializable +data class TokenResponse( + val access_token: String, + val access_token_token_expired: String? = null, + val token_type: String? = null, + val expires_in: Int? = null +) \ No newline at end of file diff --git a/src/main/kotlin/model/ChartModels.kt b/src/main/kotlin/model/ChartModels.kt new file mode 100644 index 0000000..2443495 --- /dev/null +++ b/src/main/kotlin/model/ChartModels.kt @@ -0,0 +1,42 @@ +package model + +import kotlinx.serialization.Serializable + +@Serializable +data class ChartResponse( + val rt_cd: String, + val msg1: String, + val output1: ChartItem = ChartItem(), + val output2: List = emptyList() +) + +@Serializable +data class ChartItem( + val prdt_nm: String = "" // 종목명 +) + +@Serializable +data class CandleData( + val stck_bsop_date: String, // 영업 일자 + val stck_oprc: String, // 시가 + val stck_hgpr: String, // 고가 + val stck_lwpr: String, // 저가 + val stck_clpr: String, // 종가 + val acml_vol: String // 누적 거래량 +) +@Serializable +data class OverseasCandleData( + val o_sign: String = "", // 대비 기호 + val last: String = "0", // 종가 + val open: String = "0", // 시가 + val high: String = "0", // 고가 + val low: String = "0", // 저가 + val t_vol: String = "0", // 거래량 + val xy_date: String = "" // 날짜 (YYYYMMDD) +) + +@Serializable +data class OverseasChartResponse( + val output1: ChartItem = ChartItem(), + val output2: List = emptyList() +) \ No newline at end of file diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt new file mode 100644 index 0000000..a537b8c --- /dev/null +++ b/src/main/kotlin/model/StockModels.kt @@ -0,0 +1,73 @@ +package model + +import kotlinx.serialization.Serializable + +@Serializable +data class StockBalanceResponse( + val rt_cd: String = "", + val msg1: String = "", + val output1: List = emptyList(), + val output2: List = emptyList() +) + +@Serializable +data class StockHolding( + val pdno: String = "", // 상품번호 + val prdt_name: String = "", // 상품명 + val hldg_qty: String = "0", // 보유수량 + val pchs_avg_pric: String = "0", // 매입평균가 + val prpr: String = "0", // 현재가 + val evlu_pfls_rt: String = "0.0", // 평가손익률 + val evlu_amt: String = "0" // 평가금액 +) + +@Serializable +data class BalanceSummary( + val tot_evlu_amt: String = "0", // 총 평가금액 + val evlu_pfls_rt: String = "0.0", // 총 수익률 (에러 발생 지점: 기본값 추가로 해결) + val asst_icrt: String = "0.0", // 일부 환경에서 수익률 필드명 + val nass_amt: String = "0" // 순자산 금액 +) +@Serializable +data class RankingResponse( + val output: List = emptyList() +) + +enum class RankingType(val code: String, val title: String) { + RISE("0", "상승"), + FALL("1", "하락"), + VOLUME("2", "거래량"), + AMOUNT("3", "금액"), + OVERTIME("4", "시간외"), // 웹 소스의 시간외 상승 TR 연동 + SHORT_HOT("5", "단기추천") // 단기 과열 및 추천 종목 +} + + +@Serializable +data class RankingStock( + val hts_kor_alph_nm: String = "", // 종목명 + val mkrtc_objt_iscd: String = "", // 종목코드 + val stck_prpr: String = "0", // 현재가 + val prdy_ctrt: String = "0.0" // 등락률 +) +@Serializable +data class OverseasRankingResponse( + val output: List = emptyList() +) + +@Serializable +data class OverseasRankingStock( + val hts_kor_alph_nm: String, // 종목명 + val mkrtc_objt_iscd: String, // 종목코드 (Ticker) + val last: String, // 현재가 + val diff: String, // 전일대비 + val rate: String // 등락률 +) { + // 국내용 RankingStock과 호환되도록 변환 함수 추가 + fun toRankingStock() = RankingStock( + hts_kor_alph_nm = hts_kor_alph_nm, + mkrtc_objt_iscd = mkrtc_objt_iscd, + stck_prpr = last, + prdy_ctrt = rate + ) +} \ No newline at end of file diff --git a/src/main/kotlin/model/TradeModels.kt b/src/main/kotlin/model/TradeModels.kt new file mode 100644 index 0000000..d7dbbc6 --- /dev/null +++ b/src/main/kotlin/model/TradeModels.kt @@ -0,0 +1,14 @@ +package model + +import kotlinx.serialization.Serializable + +@Serializable +data class RealTimeTrade( + val time: String, // 체결 시간 (HH:mm:ss) + val price: String, // 체결가 + val change: String, // 전일 대비 (대비 기호 포함) + val volume: String, // 체결량 + val type: TradeType // 매수체결(빨강) / 매도체결(파랑) +) + +enum class TradeType { BUY, SELL, NEUTRAL } \ No newline at end of file diff --git a/src/main/kotlin/network/AiService.kt b/src/main/kotlin/network/AiService.kt new file mode 100644 index 0000000..6249660 --- /dev/null +++ b/src/main/kotlin/network/AiService.kt @@ -0,0 +1,97 @@ +package network + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import model.RealTimeTrade + +object AiService { + private val client = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + } + + private const val LLM_URL = "http://localhost:8080/completion" + + /** + * 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다. + */ + suspend fun fetchAnalysis( + stockName: String, + currentPrice: String, + trades: List + ): String { + // 최근 체결 내역 10개를 텍스트로 요약 + val tradeSummary = trades.take(10).joinToString("\n") { trade -> + "- ${trade.time}: ${trade.price}원 (${trade.volume}주 ${if (trade.type.name == "BUY") "매수" else "매도"})" + } + + // Gemma에게 전달할 프롬프트 구성 + val prompt = """ + user + 당신은 20년 경력의 전문 주식 트레이더이자 데이터 분석가입니다. + 다음 데이터를 바탕으로 해당 종목의 현재 '수급 상황'과 '단기 전망'을 분석하여 3줄 이내로 핵심만 말해주세요. + + [종목 정보] + - 종목명: $stockName + - 현재가: $currentPrice + + [최근 실시간 체결 내역] + $tradeSummary + + 분석 기준: + 1. 매수 체결 비중이 높은지, 매도 체결 비중이 높은지 판단하세요. + 2. 대량 체결(고래)의 움직임이 있는지 확인하세요. + 3. 단기적으로 진입하기에 적절한 시점인지 조언하세요. + + 답변은 한국어로, 친절하지만 단호한 전문가 말투를 사용하세요. + model + + """.trimIndent() + + return try { + val response = client.post(LLM_URL) { + contentType(ContentType.Application.Json) + setBody(LlamaRequest(prompt = prompt)) + } + + if (response.status == HttpStatusCode.OK) { + val result: LlamaResponse = response.body() + result.content.trim() + } else { + "AI 서버 응답 오류: ${response.status}" + } + } catch (e: Exception) { + "분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})" + } + } +} + +/** + * llama.cpp 서버 요청 데이터 구조 + */ +@Serializable +data class LlamaRequest( + val prompt: String, + val n_predict: Int = 256, + val temperature: Double = 0.7, + val stop: List = listOf("<|end_of_turn|>", "") +) + +/** + * llama.cpp 서버 응답 데이터 구조 + */ +@Serializable +data class LlamaResponse( + val content: String +) \ No newline at end of file diff --git a/src/main/kotlin/network/KisAuthService.kt b/src/main/kotlin/network/KisAuthService.kt new file mode 100644 index 0000000..fb73661 --- /dev/null +++ b/src/main/kotlin/network/KisAuthService.kt @@ -0,0 +1,70 @@ +package network + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.bodyAsText +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import model.TokenRequest +import model.TokenResponse + +class KisAuthService { + private val client = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정 + }) + } + // 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능) + install(Logging) { + level = LogLevel.BODY + } + } + + private fun getBaseUrl(isSimulation: Boolean): String { + return if (isSimulation) { + "https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨 + } else { + "https://openapi.koreainvestment.com:9443" + } + } + + suspend fun fetchAccessToken( + appKey: String, + secretKey: String, + isSimulation: Boolean + ): Result { + return try { + val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP" + + val response = client.post(url) { + // 헤더 설정 (매우 중요) + contentType(ContentType.Application.Json) + + // 요청 바디 (TokenRequest 객체 전달) + setBody(TokenRequest( + "client_credentials", + appKey, + secretKey + )) + } + + if (response.status == HttpStatusCode.OK) { + Result.success(response.body()) + } else { + val errorBody = response.bodyAsText() + println("HTTP ${response.status}: $errorBody") + Result.failure(Exception("HTTP ${response.status}: $errorBody")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt new file mode 100644 index 0000000..a84a819 --- /dev/null +++ b/src/main/kotlin/network/KisTradeService.kt @@ -0,0 +1,320 @@ +package network + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.* +import io.ktor.client.statement.bodyAsText +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import model.AppConfig +import model.CandleData +import model.ChartResponse +import model.OverseasChartResponse +import model.OverseasRankingResponse +import model.RankingResponse +import model.RankingStock +import model.RankingType +import model.StockBalanceResponse + +class KisTradeService(private val isSimulation: Boolean) { + private val client = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY + } + } + + + suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result> { + 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() + Result.success(body.output.take(20)) + } catch (e: Exception) { + println("❌ [ERR] 국내 전일 등락 실패: ${e.message}") + Result.failure(e) + } + } + + /** + * [2] 국내 실시간 마켓 랭킹 (장중용) + * TR ID: FHPST01700000 + */ + suspend fun fetchMarketRanking( + token: String, + config: AppConfig, + type: RankingType, + isDomestic: Boolean + ): Result> { + if (!isDomestic) return Result.failure(Exception("Domestic only")) + + return try { + // [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank + val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank" + println("📡 [REQ] 국내 실시간 랭킹 조회: $url") + + val response = client.get(url) { + header("authorization", "Bearer $token") + header("appkey", config.appKey) + header("appsecret", config.secretKey) + header("tr_id", "FHPST01700000") + header("custtype", "P") + header("Content-Type", "application/json; charset=utf-8") + + parameter("fid_cond_mrkt_div_code", "J") + parameter("fid_cond_scr_div_code", "20170") + parameter("fid_input_iscd", "0000") + parameter("fid_div_cls_code", "0") + parameter("fid_rank_sort_cls_code", type.code) + parameter("fid_etc_cls_code", "0") + } + + if (response.status != HttpStatusCode.OK) { + val errorBody = response.bodyAsText() + println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody") + return Result.failure(Exception("HTTP ${response.status}")) + } + + val body = response.body() + 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> { + 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() + Result.success(body.output.map { it.toRankingStock() }.take(20)) + } catch (e: Exception) { Result.failure(e) } + } + + + + + private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443" + else "https://openapi.koreainvestment.com:9443" + + suspend fun fetchBalance( + token: String, + appKey: String, + appSecret: String, + accountNo: String + ): Result { + return try { + val cleanAccount = accountNo.filter { it.isDigit() } + if (cleanAccount.length != 10) { + return Result.failure(Exception("계좌번호 10자리를 입력해주세요.")) + } + val cano = cleanAccount.take(8) + val acntCd = cleanAccount.takeLast(2) + + // 웹 소스(KisApiService.kt) 54행 로직 적용 + // 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님) + val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R" + + val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") { + header("authorization", "Bearer $token") + header("appkey", appKey) + header("appsecret", appSecret) + header("tr_id", trId) + header("custtype", "P") + + // 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅 + parameter("CANO", cano) + parameter("ACNT_PRDT_CD", acntCd) + parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN + parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN + parameter("INQR_DVSN", "02") + parameter("UNPR_DVSN", "01") + parameter("FUND_STTL_ICLD_YN", "N") + parameter("FNCG_AMT_AUTO_RDPT_YN", "N") + parameter("PRCS_DVSN", "00") + parameter("CTX_AREA_FK100", "") + parameter("CTX_AREA_NK100", "") + } + + if (response.status == HttpStatusCode.OK) { + val body = response.body() + 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 { + return try { + val response = client.get("$baseUrl/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") { + header("authorization", "Bearer $token") + header("appkey", appKey) + header("appsecret", appSecret) + header("tr_id", "FHKST03010100") // 국내주식 기간별 시세 TR ID + header("custtype", "P") + + parameter("FID_COND_SCR_DIV_CODE", "16.4") + parameter("FID_INPUT_ISCD", stockCode) + parameter("FID_INPUT_DATE_1", "20240101") // 시작일 (예시) + parameter("FID_INPUT_DATE_2", "20260110") // 종료일 + parameter("FID_PERIOD_DIV_CODE", "D") // 일봉 + parameter("FID_ORG_ADJ_PRC", "0") // 수정주가 반영 + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? { + return try { + val response = client.post("$baseUrl/oauth2/Approval") { + header("Content-Type", "application/json") + setBody(mapOf("grant_type" to "client_credentials", "appkey" to appKey, "secretkey" to appSecret)) + } + // 응답에서 approval_key만 추출 (실제 모델 정의 필요) + val json = response.body>() + json["approval_key"] + } catch (e: Exception) { + null + } + } + + suspend fun fetchOverseasChartData( + token: String, + appKey: String, + appSecret: String, + stockCode: String, + excd: String = "NAS" // 기본 나스닥 + ): Result> { + 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() + // 해외 데이터를 공통 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 { + 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>() + if (body["rt_cd"] == "0") { + Result.success("주문 성공: ${body["msg1"]}") + } else { + Result.failure(Exception("${body["msg1"]}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt new file mode 100644 index 0000000..b89a49e --- /dev/null +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -0,0 +1,131 @@ +package network + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.websocket.* +import io.ktor.http.* +import io.ktor.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.consumeAsFlow +import model.RealTimeTrade +import model.TradeType + +class KisWebSocketManager(private val isSimulation: Boolean) { + val client = HttpClient(CIO) { + install(WebSockets) { + // 타임아웃 설정 (필요 시) + pingInterval = 20_000 + } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림 + socketTimeoutMillis = 15_000 + } + } + private var session: DefaultClientWebSocketSession? = null + + // Coroutine 관리용 스코프 정의 + private val scope = CoroutineScope(Dispatchers.Default + Job()) + + // UI에서 관찰할 상태값들 + val currentPrice = mutableStateOf("0") + val priceChangeColor = mutableStateOf(Color.Transparent) + val tradeLogs = mutableStateListOf() // 실시간 체결 내역 리스트 + + suspend fun connect(approvalKey: String) { + val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com" + val port = if (isSimulation) 21001 else 21000 + + scope.launch { + try { + client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") { + session = this + // 서버로부터 오는 메시지 수신 루프 + incoming.consumeAsFlow().collect { frame -> + if (frame is Frame.Text) { + parseTradeData(frame.readText()) + } + } + } + } catch (e: Exception) { + println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}") + e.printStackTrace() + } + } + } + + private fun parseTradeData(data: String) { + // 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터 + val parts = data.split("|") + if (parts.size > 3) { + val rows = parts[3].split("^") + if (rows.size > 15) { + val newTrade = RealTimeTrade( + time = rows[1].chunked(2).joinToString(":"), // HHMMSS -> HH:MM:SS + price = rows[2], + change = rows[4], + volume = rows[12], + type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL + ) + + // 메인 스레드에서 UI 상태 업데이트 + CoroutineScope(Dispatchers.Main).launch { + tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로 + if (tradeLogs.size > 30) tradeLogs.removeLast() + + // 현재가 및 색상 업데이트 로직 포함 가능 + currentPrice.value = newTrade.price + } + } + } + + } + private fun updatePriceWithEffect(newPrice: String) { + val oldPrice = currentPrice.value.replace(",", "").toIntOrNull() ?: 0 + val current = newPrice.toIntOrNull() ?: 0 + + currentPrice.value = String.format("%, d", current) + priceChangeColor.value = when { + current > oldPrice -> Color.Red.copy(alpha = 0.2f) + current < oldPrice -> Color.Blue.copy(alpha = 0.2f) + else -> Color.Transparent + } + } + + suspend fun subscribeStock(stockCode: String) { + val session = session ?: return + + // 이전 구독이 있다면 해지 로직이 필요할 수 있으나, + // 기본적으로 새로운 종목 구독 메시지를 전송합니다. + val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장) + + val requestJson = """ + { + "header": { + "approval_key": "$approvalKey", + "custtype": "P", + "tr_type": "1", + "content-type": "utf-8" + }, + "body": { + "input": { + "tr_id": "H0STCNT0", + "tr_key": "$stockCode" + } + } + } + """.trimIndent() + + try { + session.send(Frame.Text(requestJson)) + // 기존 체결 로그 초기화 + tradeLogs.clear() + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/network/LlamaServerManager.kt b/src/main/kotlin/network/LlamaServerManager.kt new file mode 100644 index 0000000..bc1deb3 --- /dev/null +++ b/src/main/kotlin/network/LlamaServerManager.kt @@ -0,0 +1,53 @@ +package network + +import java.io.File +import java.io.BufferedReader +import java.io.InputStreamReader +import kotlinx.coroutines.* + +object LlamaServerManager { + private var process: Process? = null + private val scope = CoroutineScope(Dispatchers.IO + Job()) + + fun startServer(binPath: String, modelPath: String) { + if (process != null) return // 이미 실행 중이면 무시 + + val command = listOf( + binPath, + "-m", modelPath, + "--port", "8080", + "-c", "2048", // 컨텍스트 길이 + "-t", "4", // 인텔 맥 코어 수에 맞춰 스레드 제한 (부하 방지) + "--embedding" // 나중에 유사도 분석 등을 위해 활성화 + ) + + scope.launch { + try { + val pb = ProcessBuilder(command) + // 실행 파일 권한 확인 (자동 부여) + File(binPath).setExecutable(true) + + process = pb.start() + println("✅ AI 서버 시작됨: http://localhost:8080") + + // 서버 로그 모니터링 (에러 디버깅용) + val reader = BufferedReader(InputStreamReader(process?.inputStream)) + var line: String? + while (reader.readLine().also { line = it } != null) { + // 서버 준비 완료 로그 확인용 + if (line?.contains("HTTP server listening") == true) { + println("🚀 AI 모델 로딩 완료 및 대기 중") + } + } + } catch (e: Exception) { + println("❌ AI 서버 실행 실패: ${e.message}") + } + } + } + + fun stopServer() { + process?.destroy() + process = null + println("🛑 AI 서버 종료") + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/AiAnalysisView.kt b/src/main/kotlin/ui/AiAnalysisView.kt new file mode 100644 index 0000000..f119979 --- /dev/null +++ b/src/main/kotlin/ui/AiAnalysisView.kt @@ -0,0 +1,86 @@ +package ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.* +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 kotlinx.coroutines.launch +import model.RealTimeTrade +import network.AiService + +@Composable +fun AiAnalysisView(stockName: String, currentPrice: String, trades: List) { + var aiOpinion by remember { mutableStateOf("분석 대기 중...") } + var isLoading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // 1. 모델 경로 유효성 체크 + val isModelConfigured = remember { + val path = util.AppConfigManager.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) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isModelConfigured) "🤖 AI 투자 전략" else "⚠️ AI 설정 필요", + fontWeight = FontWeight.Bold, + color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red + ) + Spacer(Modifier.weight(1f)) + + // 2. 경로가 정상일 때만 버튼 활성화 + Button( + onClick = { + scope.launch { + isLoading = true + aiOpinion = "Gemma가 데이터를 읽고 있습니다..." + aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades) + isLoading = false + } + }, + enabled = isModelConfigured && !isLoading, // 유효성 체크 반영 + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.White, + disabledBackgroundColor = Color(0xFFE0E0E0) + ) + ) { + 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) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/CandleChart.kt b/src/main/kotlin/ui/CandleChart.kt new file mode 100644 index 0000000..e9b090b --- /dev/null +++ b/src/main/kotlin/ui/CandleChart.kt @@ -0,0 +1,65 @@ +package ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import model.CandleData + +@Composable +fun CandleChart(data: List, modifier: Modifier = Modifier) { + if (data.isEmpty()) return + + Canvas(modifier = modifier.fillMaxSize()) { + val width = size.width + val height = size.height + val candleCount = data.size + val candleWidth = width / candleCount + val spacing = candleWidth * 0.2f // 캔들 사이 간격 + + // 1. 가격 범위 계산 (스케일링용) + val maxPrice = data.maxOf { it.stck_hgpr.toDouble() } + val minPrice = data.minOf { it.stck_lwpr.toDouble() } + val priceRange = maxPrice - minPrice + + fun getY(price: Double): Float { + 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 isRising = close >= open + val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF) + + val x = index * candleWidth + spacing / 2 + val currentCandleWidth = candleWidth - spacing + + // 2. 꼬리 그리기 (High-Low Line) + drawLine( + color = color, + start = Offset(x + currentCandleWidth / 2, getY(high)), + end = Offset(x + currentCandleWidth / 2, getY(low)), + strokeWidth = 2f + ) + + // 3. 몸통 그리기 (Open-Close Rect) + val bodyTop = getY(maxOf(open, close)) + val bodyBottom = getY(minOf(open, close)) + val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장 + + drawRect( + color = color, + topLeft = Offset(x, bodyTop), + size = Size(currentCandleWidth, bodyHeight) + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt new file mode 100644 index 0000000..9301aa1 --- /dev/null +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -0,0 +1,302 @@ +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 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) } + + // 전역 상태: 현재 선택된 종목 + var selectedStockCode by remember { mutableStateOf("") } + var selectedStockName by remember { mutableStateOf("") } + + // 잔고 데이터 상태 + var holdings by remember { mutableStateOf>(emptyList()) } + var summary by remember { mutableStateOf(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() + } + } + + // 메인 3분할 레이아웃 + Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { + + // [좌측 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 -> + selectedStockCode = code + selectedStockName = name + } + } + + VerticalDivider() + + // [중앙 45%] 실시간 차트 및 주문 (가장 중요) + Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) { + if (selectedStockCode.isNotEmpty()) { + StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray) + } + } + } + + VerticalDivider() + + // [우측 30%] 시장 추천 리스트 (탭 방식) + 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 -> + selectedStockCode = code + selectedStockName = name + } + } + } +} + +@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) + ) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/MyStockList.kt b/src/main/kotlin/ui/MyStockList.kt new file mode 100644 index 0000000..a0f08bd --- /dev/null +++ b/src/main/kotlin/ui/MyStockList.kt @@ -0,0 +1,115 @@ +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.StockHolding +import network.KisTradeService + +@Composable +fun MyStockList( + holdings: List, + onSelect: (code: String, name: String) -> Unit +) { + if (holdings.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("보유 종목이 없습니다.", color = Color.Gray, style = MaterialTheme.typography.body2) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(holdings) { stock -> + MyStockItemRow(stock) { + onSelect(stock.pdno, stock.prdt_name) + } + } + } + } +} + +@Composable +fun MyStockItemRow( + stock: StockHolding, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + elevation = 1.dp, + shape = RoundedCornerShape(4.dp), + backgroundColor = Color.White + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 1. 종목명 및 코드 + Column(modifier = Modifier.weight(1f)) { + Text( + text = stock.prdt_name, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stock.pdno, + style = MaterialTheme.typography.caption, + color = Color.Gray + ) + } + + // 2. 수익률 배지 (웹 소스 컬러 적용) + val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0 + val color = when { + rate > 0 -> Color(0xFFE03E2D) // 매수색 + rate < 0 -> Color(0xFF0E62CF) // 매도색 + else -> Color.DarkGray + } + + Column(horizontalAlignment = Alignment.End) { + Surface( + color = color.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "${if (rate > 0) "+" else ""}${stock.evlu_pfls_rt}%", + color = color, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + Text( + text = "${stock.prpr}원", + style = MaterialTheme.typography.caption, + color = Color.DarkGray + ) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/OrderSection.kt b/src/main/kotlin/ui/OrderSection.kt new file mode 100644 index 0000000..4c074eb --- /dev/null +++ b/src/main/kotlin/ui/OrderSection.kt @@ -0,0 +1,112 @@ +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.TextAlign +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.CandleData +import model.RankingStock +import model.StockHolding +import network.KisTradeService +import network.KisWebSocketManager +import kotlin.collections.isNotEmpty + +@Composable +fun OrderSection( + config: AppConfig, + token: String, + stockCode: String, + currentPrice: String, + onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달 +) { + val scope = rememberCoroutineScope() // 에러 해결: scope 정의 + val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의 + var orderQty by remember { mutableStateOf("1") } + var orderPrice by remember { mutableStateOf("0") } // 0은 시장가 + var isSubmitting by remember { mutableStateOf(false) } + + 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 + ) + 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)) + + 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 + } + }, + modifier = Modifier.weight(1f).height(45.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)), + enabled = !isSubmitting + ) { + Text("현금매수", color = Color.White, fontWeight = FontWeight.Bold) + } + + // 매도 버튼 + 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 + } + }, + modifier = Modifier.weight(1f).height(45.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)), + enabled = !isSubmitting + ) { + Text("현금매도", color = Color.White, fontWeight = FontWeight.Bold) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/RecommendationTabs.kt b/src/main/kotlin/ui/RecommendationTabs.kt new file mode 100644 index 0000000..bb5576a --- /dev/null +++ b/src/main/kotlin/ui/RecommendationTabs.kt @@ -0,0 +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>(emptyList()) } + + val tradeService = remember { KisTradeService(config.isSimulation) } + val isKoreaOpen = MarketUtil.isKoreanMarketOpen() + var errorMessage by remember { mutableStateOf(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 + ) + } +} diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt new file mode 100644 index 0000000..c33a1eb --- /dev/null +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -0,0 +1,164 @@ +package ui + +import androidx.compose.foundation.border +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 +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.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import model.AppConfig +import network.KisAuthService +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 // 파일 선택기용 + + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SettingsScreen( + initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요 + onAuthSuccess: (AppConfig, String) -> Unit +) { + val scope = rememberCoroutineScope() + val authService = remember { KisAuthService() } + + // 화면 입력 상태값 + 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) } + + 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()) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it }) + Text("모의투자 서버 사용") + } + + 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 = "파일 선택") + } + } + + // 드래그 앤 드롭 영역 + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(top = 8.dp) + .border(1.dp, Color.LightGray, 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 + } + }), + contentAlignment = Alignment.Center + ) { + Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray) + } + + Spacer(modifier = 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 + ) + + 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 + ) + } else { + Text("설정 저장 및 접속 시작") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text(statusMessage, color = if (statusMessage.contains("✅")) Color.Green else Color.Gray) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt new file mode 100644 index 0000000..94155f0 --- /dev/null +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -0,0 +1,182 @@ +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.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import model.AppConfig +import model.BalanceSummary +import model.CandleData +import model.RankingStock +import model.StockHolding +import network.KisTradeService +import network.KisWebSocketManager +import kotlin.collections.isNotEmpty + +@Composable +fun StockDetailArea( + config: AppConfig, + token: String, + code: String, + name: String, + 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>(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 + + 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) + } + + result.onSuccess { chartData = it } + .onFailure { println("차트 로드 실패: ${it.message}") } + + isLoading = false + } + + LaunchedEffect(resultMessage) { + if (resultMessage.isNotEmpty()) { + delay(3000) + resultMessage = "" + } + } + + 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 + ) + } + } + + 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) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // [차트 영역] CandleChart 컴포저블 재사용 + Card( + modifier = Modifier.fillMaxWidth().height(350.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) } + } + } + Spacer(modifier = Modifier.height(16.dp)) + AiAnalysisView( + stockName = name, + 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)) { + 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) + } + + // 실시간 리스트 + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(tradeLogs) { trade -> + TradeLogRow(trade) + Divider(color = Color(0xFFF5F5F5)) + } + } + } + OrderSection( + config = config, + token = token, + stockCode = code, + currentPrice = currentPrice, + onOrderResult = { msg, success -> + resultMessage = msg + isSuccess = success + } + ) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/ui/TradeLogRow.kt b/src/main/kotlin/ui/TradeLogRow.kt new file mode 100644 index 0000000..775c092 --- /dev/null +++ b/src/main/kotlin/ui/TradeLogRow.kt @@ -0,0 +1,63 @@ +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.TextAlign +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.RealTimeTrade +import model.StockHolding +import model.TradeType +import network.KisTradeService +import util.MarketUtil + +@Composable +fun TradeLogRow(trade: RealTimeTrade) { + val color = when (trade.type) { + TradeType.BUY -> Color(0xFFE03E2D) + TradeType.SELL -> Color(0xFF0E62CF) + else -> Color.DarkGray + } + + // 대량 체결(예: 1000주 이상) 시 연한 배경색 강조 + val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000 + val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent + + Row( + modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center) + Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End) + Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End) + Text( + text = trade.volume, + modifier = Modifier.weight(1f), + fontSize = 12.sp, + fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal, + color = color, + textAlign = TextAlign.End + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/util/AppConfigManager.kt b/src/main/kotlin/util/AppConfigManager.kt new file mode 100644 index 0000000..0ccf6ab --- /dev/null +++ b/src/main/kotlin/util/AppConfigManager.kt @@ -0,0 +1,38 @@ +package util + +import java.io.File +import java.util.Properties +object AppConfigManager { + private val props = Properties() + private val configFile = File("app.properties") + + // API 및 계좌 설정 + var appKey: String by PropertyDelegate("app_key", "") + var secretKey: String by PropertyDelegate("secret_key", "") + var accountNo: String by PropertyDelegate("account_no", "") + var isSimulation: Boolean by PropertyDelegate("is_simulation", "true") { it.toBoolean() } + + // AI 모델 설정 + var modelPath: String by PropertyDelegate("model_path", "") + + init { + if (configFile.exists()) configFile.inputStream().use { props.load(it) } + } + + private fun save() = configFile.outputStream().use { props.store(it, "AutoTrade Config") } + + // 델리게이트 패턴으로 중복 코드 방지 + class PropertyDelegate( + private val key: String, + private val default: String, + private val parser: (String) -> T = { it as T } + ) { + operator fun getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): T = + parser(props.getProperty(key, default)) + + operator fun setValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: T) { + props.setProperty(key, value.toString()) + save() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/util/MarketUtil.kt b/src/main/kotlin/util/MarketUtil.kt new file mode 100644 index 0000000..978cd21 --- /dev/null +++ b/src/main/kotlin/util/MarketUtil.kt @@ -0,0 +1,17 @@ +package util + +import java.time.LocalTime +import java.time.ZoneId + +object MarketUtil { + fun isKoreanMarketOpen(): Boolean { + // 한국 시간대 기준 현재 시간 가져오기 + val now = LocalTime.now(ZoneId.of("Asia/Seoul")) + val start = LocalTime.of(9, 0) + val end = LocalTime.of(15, 30) + + // 주말 제외 로직은 필요시 추가 (일단 시간대 우선) + return now.isAfter(start) && now.isBefore(end) + } + +} \ No newline at end of file diff --git a/src/main/resources/bin/llama-server b/src/main/resources/bin/llama-server new file mode 100755 index 0000000..b05226c Binary files /dev/null and b/src/main/resources/bin/llama-server differ