This commit is contained in:
lunaticbum 2026-01-10 18:16:50 +09:00
parent 547a00b139
commit d4770af62f
28 changed files with 2406 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -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

62
build.gradle.kts Normal file
View File

@ -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 사용을 강제합니다.
}

BIN
db/autotrade_db.mv.db Normal file

Binary file not shown.

18
settings.gradle.kts Normal file
View File

@ -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"

84
src/main/kotlin/Main.kt Normal file
View File

@ -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<AppConfig?>(null) }
var token by remember { mutableStateOf("") }
var selectedStockCode by remember { mutableStateOf<String?>(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)
}
}
}
}
}

View File

@ -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<TradeLog> {
// return transaction {
// TradeLogTable.selectAll()
// .orderBy(TradeLogTable.timestamp to SortOrder.DESC)
// .limit(limit)
// .map {
// // ResultRow를 객체로 변환
// }
// }
// }
}

View File

@ -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 = "" // 추가된 필드
)

View File

@ -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
)

View File

@ -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<CandleData> = 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<OverseasCandleData> = emptyList()
)

View File

@ -0,0 +1,73 @@
package model
import kotlinx.serialization.Serializable
@Serializable
data class StockBalanceResponse(
val rt_cd: String = "",
val msg1: String = "",
val output1: List<StockHolding> = emptyList(),
val output2: List<BalanceSummary> = 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<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", "단기추천") // 단기 과열 및 추천 종목
}
@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<OverseasRankingStock> = 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
)
}

View File

@ -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 }

View File

@ -0,0 +1,97 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import model.RealTimeTrade
object AiService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
}
private const val LLM_URL = "http://localhost:8080/completion"
/**
* 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다.
*/
suspend fun fetchAnalysis(
stockName: String,
currentPrice: String,
trades: List<RealTimeTrade>
): String {
// 최근 체결 내역 10개를 텍스트로 요약
val tradeSummary = trades.take(10).joinToString("\n") { trade ->
"- ${trade.time}: ${trade.price}원 (${trade.volume}${if (trade.type.name == "BUY") "매수" else "매도"})"
}
// Gemma에게 전달할 프롬프트 구성
val prompt = """
<start_of_turn>user
당신은 20 경력의 전문 주식 트레이더이자 데이터 분석가입니다.
다음 데이터를 바탕으로 해당 종목의 현재 '수급 상황' '단기 전망' 분석하여 3 이내로 핵심만 말해주세요.
[종목 정보]
- 종목명: $stockName
- 현재가: $currentPrice
[최근 실시간 체결 내역]
$tradeSummary
분석 기준:
1. 매수 체결 비중이 높은지, 매도 체결 비중이 높은지 판단하세요.
2. 대량 체결(고래) 움직임이 있는지 확인하세요.
3. 단기적으로 진입하기에 적절한 시점인지 조언하세요.
답변은 한국어로, 친절하지만 단호한 전문가 말투를 사용하세요.<end_of_turn>
<start_of_turn>model
""".trimIndent()
return try {
val response = client.post(LLM_URL) {
contentType(ContentType.Application.Json)
setBody(LlamaRequest(prompt = prompt))
}
if (response.status == HttpStatusCode.OK) {
val result: LlamaResponse = response.body()
result.content.trim()
} else {
"AI 서버 응답 오류: ${response.status}"
}
} catch (e: Exception) {
"분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})"
}
}
}
/**
* llama.cpp 서버 요청 데이터 구조
*/
@Serializable
data class LlamaRequest(
val prompt: String,
val n_predict: Int = 256,
val temperature: Double = 0.7,
val stop: List<String> = listOf("<|end_of_turn|>", "<end_of_turn>")
)
/**
* llama.cpp 서버 응답 데이터 구조
*/
@Serializable
data class LlamaResponse(
val content: String
)

View File

@ -0,0 +1,70 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import model.TokenRequest
import model.TokenResponse
class KisAuthService {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값(grant_type)이 누락되지 않도록 설정
})
}
// 디버깅을 위해 로그 추가 (인텔 맥 콘솔에서 전송 데이터 확인 가능)
install(Logging) {
level = LogLevel.BODY
}
}
private fun getBaseUrl(isSimulation: Boolean): String {
return if (isSimulation) {
"https://openapivts.koreainvestment.com:29443" // 'openapi' 추가됨
} else {
"https://openapi.koreainvestment.com:9443"
}
}
suspend fun fetchAccessToken(
appKey: String,
secretKey: String,
isSimulation: Boolean
): Result<TokenResponse> {
return try {
val url = "${getBaseUrl(isSimulation)}/oauth2/tokenP"
val response = client.post(url) {
// 헤더 설정 (매우 중요)
contentType(ContentType.Application.Json)
// 요청 바디 (TokenRequest 객체 전달)
setBody(TokenRequest(
"client_credentials",
appKey,
secretKey
))
}
if (response.status == HttpStatusCode.OK) {
Result.success(response.body())
} else {
val errorBody = response.bodyAsText()
println("HTTP ${response.status}: $errorBody")
Result.failure(Exception("HTTP ${response.status}: $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@ -0,0 +1,320 @@
package network
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.*
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import model.AppConfig
import model.CandleData
import model.ChartResponse
import model.OverseasChartResponse
import model.OverseasRankingResponse
import model.RankingResponse
import model.RankingStock
import model.RankingType
import model.StockBalanceResponse
class KisTradeService(private val isSimulation: Boolean) {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO // 상세 로그 원하면 LogLevel.BODY
}
}
suspend fun fetchDomesticPreviousDayRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/pdy-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/pdy-rank"
println("📡 [REQ] 국내 전일 등락 조회: $url")
val response = client.get(url) {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "HHPST01710000")
header("custtype", "P")
header("Content-Type", "application/json; charset=utf-8") // 헤더 명시
parameter("fid_cond_mrkt_div_code", "J")
parameter("fid_cond_scr_div_code", "20171")
parameter("fid_input_iscd", "0000")
parameter("fid_rank_sort_cls_code", "0")
parameter("fid_input_cntstr_value", "")
parameter("fid_prc_cls_code", "1")
}
if (response.status != HttpStatusCode.OK) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}: $errorBody"))
}
val body = response.body<RankingResponse>()
Result.success(body.output.take(20))
} catch (e: Exception) {
println("❌ [ERR] 국내 전일 등락 실패: ${e.message}")
Result.failure(e)
}
}
/**
* [2] 국내 실시간 마켓 랭킹 (장중용)
* TR ID: FHPST01700000
*/
suspend fun fetchMarketRanking(
token: String,
config: AppConfig,
type: RankingType,
isDomestic: Boolean
): Result<List<RankingStock>> {
if (!isDomestic) return Result.failure(Exception("Domestic only"))
return try {
// [수정] URL 경로 확인: /uapi/domestic-stock/v1/quotations/volume-rank
val url = "$baseUrl/uapi/domestic-stock/v1/quotations/volume-rank"
println("📡 [REQ] 국내 실시간 랭킹 조회: $url")
val response = client.get(url) {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "FHPST01700000")
header("custtype", "P")
header("Content-Type", "application/json; charset=utf-8")
parameter("fid_cond_mrkt_div_code", "J")
parameter("fid_cond_scr_div_code", "20170")
parameter("fid_input_iscd", "0000")
parameter("fid_div_cls_code", "0")
parameter("fid_rank_sort_cls_code", type.code)
parameter("fid_etc_cls_code", "0")
}
if (response.status != HttpStatusCode.OK) {
val errorBody = response.bodyAsText()
println("⚠️ [WARN] 서버 응답 에러 (${response.status}): $errorBody")
return Result.failure(Exception("HTTP ${response.status}"))
}
val body = response.body<RankingResponse>()
Result.success(body.output.take(20))
} catch (e: Exception) {
println("❌ [ERR] 실시간 랭킹 실패: ${e.message}")
Result.failure(e)
}
}
private val prodBaseUrl = "https://openapi.koreainvestment.com:9443"
// 해외 실시간/전일 등락 상위
suspend fun fetchOverseasRanking(token: String, config: AppConfig): Result<List<RankingStock>> {
return try {
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/rank-fluctuation") {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", "HHDFS76240000")
parameter("EXCD", "NAS") // 나스닥 기준
parameter("GUBN", "0") // 상승률순
}
val body = response.body<OverseasRankingResponse>()
Result.success(body.output.map { it.toRankingStock() }.take(20))
} catch (e: Exception) { Result.failure(e) }
}
private val baseUrl = if (isSimulation) "https://openapivts.koreainvestment.com:29443"
else "https://openapi.koreainvestment.com:9443"
suspend fun fetchBalance(
token: String,
appKey: String,
appSecret: String,
accountNo: String
): Result<StockBalanceResponse> {
return try {
val cleanAccount = accountNo.filter { it.isDigit() }
if (cleanAccount.length != 10) {
return Result.failure(Exception("계좌번호 10자리를 입력해주세요."))
}
val cano = cleanAccount.take(8)
val acntCd = cleanAccount.takeLast(2)
// 웹 소스(KisApiService.kt) 54행 로직 적용
// 실전: TTTC8434R / 모의: VTTC8434R (VTRP 아님)
val trId = if (isSimulation) "VTTC8434R" else "TTTC8434R"
val response = client.get("$baseUrl/uapi/domestic-stock/v1/trading/inquire-balance") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", trId)
header("custtype", "P")
// 웹 소스 61~72행 파라미터 명칭과 동일하게 세팅
parameter("CANO", cano)
parameter("ACNT_PRDT_CD", acntCd)
parameter("AFHR_FLPR_YN", "N") // 명칭 수정: AFHR_FLG -> AFHR_FLPR_YN
parameter("OFL_YN", "N") // 명칭 수정: OFL_FLG -> OFL_YN
parameter("INQR_DVSN", "02")
parameter("UNPR_DVSN", "01")
parameter("FUND_STTL_ICLD_YN", "N")
parameter("FNCG_AMT_AUTO_RDPT_YN", "N")
parameter("PRCS_DVSN", "00")
parameter("CTX_AREA_FK100", "")
parameter("CTX_AREA_NK100", "")
}
if (response.status == HttpStatusCode.OK) {
val body = response.body<StockBalanceResponse>()
if (body.rt_cd == "0") {
Result.success(body)
} else {
Result.failure(Exception("API 에러: ${body.msg1} (코드:${body.rt_cd})"))
}
} else {
Result.failure(Exception("HTTP 오류: ${response.status}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String
): Result<ChartResponse> {
return try {
val response = client.get("$baseUrl/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", "FHKST03010100") // 국내주식 기간별 시세 TR ID
header("custtype", "P")
parameter("FID_COND_SCR_DIV_CODE", "16.4")
parameter("FID_INPUT_ISCD", stockCode)
parameter("FID_INPUT_DATE_1", "20240101") // 시작일 (예시)
parameter("FID_INPUT_DATE_2", "20260110") // 종료일
parameter("FID_PERIOD_DIV_CODE", "D") // 일봉
parameter("FID_ORG_ADJ_PRC", "0") // 수정주가 반영
}
Result.success(response.body())
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchApprovalKey(appKey: String, appSecret: String): String? {
return try {
val response = client.post("$baseUrl/oauth2/Approval") {
header("Content-Type", "application/json")
setBody(mapOf("grant_type" to "client_credentials", "appkey" to appKey, "secretkey" to appSecret))
}
// 응답에서 approval_key만 추출 (실제 모델 정의 필요)
val json = response.body<Map<String, String>>()
json["approval_key"]
} catch (e: Exception) {
null
}
}
suspend fun fetchOverseasChartData(
token: String,
appKey: String,
appSecret: String,
stockCode: String,
excd: String = "NAS" // 기본 나스닥
): Result<List<CandleData>> {
return try {
val response = client.get("$baseUrl/uapi/overseas-stock/v1/quotations/inquire-daily-chartprice") {
header("authorization", "Bearer $token")
header("appkey", appKey)
header("appsecret", appSecret)
header("tr_id", "HHDFS76240000") // 해외 주식 기간별 시세 TR ID
header("custtype", "P")
parameter("EXCD", excd)
parameter("SYMB", stockCode)
parameter("GUBN", "0") // 0: 일봉, 1: 주봉, 2: 월봉
parameter("BYMD", "") // 공백 시 현재일 기준
parameter("MODP", "Y") // 수정주가 반영
}
val body = response.body<OverseasChartResponse>()
// 해외 데이터를 공통 CandleData 형식으로 변환하여 차트 컴포저블 재사용
val converted = body.output2.map {
CandleData(
stck_bsop_date = it.xy_date,
stck_oprc = it.open,
stck_hgpr = it.high,
stck_lwpr = it.low,
stck_clpr = it.last,
acml_vol = it.t_vol
)
}.reversed()
Result.success(converted)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun postOrder(
token: String,
config: AppConfig,
stockCode: String,
qty: String,
price: String, // "0"이면 시장가
isBuy: Boolean
): Result<String> {
return try {
val cleanAccount = config.accountNo.filter { it.isDigit() }
val trId = if (config.isSimulation) {
if (isBuy) "VTRP0001U" else "VTRP0002U" // 모의: 매수/매도
} else {
if (isBuy) "TTTC0802U" else "TTTC0801U" // 실전: 매수/매도
}
val response = client.post("$baseUrl/uapi/domestic-stock/v1/trading/order-cash") {
header("authorization", "Bearer $token")
header("appkey", config.appKey)
header("appsecret", config.secretKey)
header("tr_id", trId)
header("Content-Type", "application/json")
setBody(mapOf(
"CANO" to cleanAccount.take(8),
"ACNT_PRDT_CD" to cleanAccount.takeLast(2),
"PDNO" to stockCode,
"ORD_DVSN" to if (price == "0") "01" else "00", // 01:시장가, 00:지정가
"ORD_QTY" to qty,
"ORD_UNPR" to price
))
}
val body = response.body<Map<String, Any>>()
if (body["rt_cd"] == "0") {
Result.success("주문 성공: ${body["msg1"]}")
} else {
Result.failure(Exception("${body["msg1"]}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@ -0,0 +1,131 @@
package network
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.consumeAsFlow
import model.RealTimeTrade
import model.TradeType
class KisWebSocketManager(private val isSimulation: Boolean) {
val client = HttpClient(CIO) {
install(WebSockets) {
// 타임아웃 설정 (필요 시)
pingInterval = 20_000
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 15_000 // 연결 시도 시간을 15초로 늘림
socketTimeoutMillis = 15_000
}
}
private var session: DefaultClientWebSocketSession? = null
// Coroutine 관리용 스코프 정의
private val scope = CoroutineScope(Dispatchers.Default + Job())
// UI에서 관찰할 상태값들
val currentPrice = mutableStateOf("0")
val priceChangeColor = mutableStateOf(Color.Transparent)
val tradeLogs = mutableStateListOf<RealTimeTrade>() // 실시간 체결 내역 리스트
suspend fun connect(approvalKey: String) {
val hostUrl = if (isSimulation) "ops.koreainvestment.com" else "ops.koreainvestment.com"
val port = if (isSimulation) 21001 else 21000
scope.launch {
try {
client.webSocket(method = HttpMethod.Get, host = hostUrl, port = port, path = "/tryitout/H0STCNT0") {
session = this
// 서버로부터 오는 메시지 수신 루프
incoming.consumeAsFlow().collect { frame ->
if (frame is Frame.Text) {
parseTradeData(frame.readText())
}
}
}
} catch (e: Exception) {
println("⚠️ 웹소켓 연결 실패 (장외 시간 또는 서버 점검): ${e.localizedMessage}")
e.printStackTrace()
}
}
}
private fun parseTradeData(data: String) {
// 한국투자증권 데이터 포맷: 수신구분|TRID|데이터건수|체결데이터
val parts = data.split("|")
if (parts.size > 3) {
val rows = parts[3].split("^")
if (rows.size > 15) {
val newTrade = RealTimeTrade(
time = rows[1].chunked(2).joinToString(":"), // HHMMSS -> HH:MM:SS
price = rows[2],
change = rows[4],
volume = rows[12],
type = if (rows[15] == "1") TradeType.BUY else TradeType.SELL
)
// 메인 스레드에서 UI 상태 업데이트
CoroutineScope(Dispatchers.Main).launch {
tradeLogs.add(0, newTrade) // 최신 데이터를 맨 위로
if (tradeLogs.size > 30) tradeLogs.removeLast()
// 현재가 및 색상 업데이트 로직 포함 가능
currentPrice.value = newTrade.price
}
}
}
}
private fun updatePriceWithEffect(newPrice: String) {
val oldPrice = currentPrice.value.replace(",", "").toIntOrNull() ?: 0
val current = newPrice.toIntOrNull() ?: 0
currentPrice.value = String.format("%, d", current)
priceChangeColor.value = when {
current > oldPrice -> Color.Red.copy(alpha = 0.2f)
current < oldPrice -> Color.Blue.copy(alpha = 0.2f)
else -> Color.Transparent
}
}
suspend fun subscribeStock(stockCode: String) {
val session = session ?: return
// 이전 구독이 있다면 해지 로직이 필요할 수 있으나,
// 기본적으로 새로운 종목 구독 메시지를 전송합니다.
val approvalKey = "" // 연결 시 저장해둔 키 사용 (필요시 클래스 변수로 저장)
val requestJson = """
{
"header": {
"approval_key": "$approvalKey",
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": "H0STCNT0",
"tr_key": "$stockCode"
}
}
}
""".trimIndent()
try {
session.send(Frame.Text(requestJson))
// 기존 체결 로그 초기화
tradeLogs.clear()
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@ -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 서버 종료")
}
}

View File

@ -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<RealTimeTrade>) {
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)
}
}
}
}

View File

@ -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<CandleData>, 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)
)
}
}
}

View File

@ -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<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()
}
}
// 메인 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)
)
}
}
}
}
}

View File

@ -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<StockHolding>,
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
)
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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<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

@ -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)
}
}
}

View File

@ -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<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
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
}
)
}
}

View File

@ -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
)
}
}

View File

@ -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<T>(
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()
}
}
}

View File

@ -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)
}
}

Binary file not shown.